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
57 changed files with 2416 additions and 4142 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.
+1 -1
View File
@@ -16,7 +16,7 @@ experimental:
version: "{{.BadgerVersion}}" version: "{{.BadgerVersion}}"
crowdsec: # CrowdSec plugin configuration added crowdsec: # CrowdSec plugin configuration added
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.7.1" version: "v1.4.4"
log: log:
level: "INFO" level: "INFO"
+4 -26
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.",
@@ -1176,10 +1178,6 @@
"idpJmespathAboutDescriptionLink": "Learn more about JMESPath", "idpJmespathAboutDescriptionLink": "Learn more about JMESPath",
"idpJmespathLabel": "Identifier Path", "idpJmespathLabel": "Identifier Path",
"idpJmespathLabelDescription": "The path to the user identifier in the ID token", "idpJmespathLabelDescription": "The path to the user identifier in the ID token",
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
"idpIdentifierChangeConfirmMessage": "I confirm",
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
"idpJmespathEmailPathOptional": "Email Path (Optional)", "idpJmespathEmailPathOptional": "Email Path (Optional)",
"idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token", "idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token",
"idpJmespathNamePathOptional": "Name Path (Optional)", "idpJmespathNamePathOptional": "Name Path (Optional)",
@@ -1424,24 +1422,6 @@
"logoutError": "Error logging out", "logoutError": "Error logging out",
"signingAs": "Signed in as", "signingAs": "Signed in as",
"serverAdmin": "Server Admin", "serverAdmin": "Server Admin",
"promoteServerAdmin": "Promote to Server admin",
"promoteServerAdminTitle": "Promote to Server Admin",
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
"promoteServerAdminMessage": "Server admins have full access to every organization, user, and setting on this instance.",
"promoteServerAdminWarning": "This can be undone at any time by demoting the user from this page.",
"promoteServerAdminConfirm": "Promote to server admin",
"promoteServerAdminSuccess": "User promoted",
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
"promoteServerAdminError": "Failed to promote user",
"demoteServerAdmin": "Demote from Server admin",
"demoteServerAdminTitle": "Demote from Server Admin",
"demoteServerAdminQuestion": "Are you sure you want to demote {selectedUser} from server admin?",
"demoteServerAdminMessage": "{selectedUser} will lose full access to every organization, user, and setting on this instance.",
"demoteServerAdminWarning": "This can be undone at any time by promoting the user from this page.",
"demoteServerAdminConfirm": "Demote from server admin",
"demoteServerAdminSuccess": "User demoted",
"demoteServerAdminSuccessDescription": "{selectedUser} is no longer a server admin.",
"demoteServerAdminError": "Failed to demote user",
"managedSelfhosted": "Managed Self-Hosted", "managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor", "otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor", "otpDisable": "Disable Two-factor",
@@ -1595,8 +1575,6 @@
"search": "Search…", "search": "Search…",
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
"emptySearchOptions": "No options found", "emptySearchOptions": "No options found",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Create", "create": "Create",
"orgs": "Organizations", "orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.", "loginError": "An unexpected error occurred. Please try again.",
@@ -2113,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",
@@ -2620,7 +2599,6 @@
"createDomainType": "Type:", "createDomainType": "Type:",
"createDomainName": "Name:", "createDomainName": "Name:",
"createDomainValue": "Value:", "createDomainValue": "Value:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Records", "createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records", "createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}", "createDomainRecordNumber": "Record {number}",
@@ -3018,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets", "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels", "remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.", "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...", "remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
+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",
-38
View File
@@ -1,38 +0,0 @@
import { and, asc, eq, or } from "drizzle-orm";
import { Transaction, User, userOrgs, users } from "@server/db";
export async function findOrgUserByIdentifier(
trx: Transaction,
orgId: string,
identifier: string
): Promise<User | null> {
const [match] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, identifier), eq(users.email, identifier)),
eq(userOrgs.orgId, orgId)
)
)
.orderBy(asc(users.dateCreated), asc(users.userId))
.limit(1);
return match?.user ?? null;
}
export async function resolveOrgUserIds(
trx: Transaction,
orgId: string,
identifiers: string[]
): Promise<string[]> {
const userIds = new Set<string>();
for (const identifier of identifiers) {
const user = await findOrgUserByIdentifier(trx, orgId, identifier);
if (user) {
userIds.add(user.userId);
}
}
return [...userIds];
}
+35 -22
View File
@@ -11,14 +11,15 @@ import {
siteNetworks, siteNetworks,
siteResources, siteResources,
Transaction, Transaction,
userOrgs,
users,
userSiteResources, userSiteResources,
networks networks
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels"; import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import { resolveOrgUserIds } from "./findOrgUser";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
@@ -388,23 +389,29 @@ export async function updatePrivateResources(
.where(eq(userSiteResources.siteResourceId, siteResourceId)); .where(eq(userSiteResources.siteResourceId, siteResourceId));
if (resourceData.users.length > 0) { if (resourceData.users.length > 0) {
const userIds = await resolveOrgUserIds( // get userIds from username
trx, const usersToUpdate = await trx
orgId, .select()
resourceData.users .from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId)
)
); );
if (userIds.length > 0) { const userIds = usersToUpdate.map((user) => user.user.userId);
await trx await trx
.insert(userSiteResources) .insert(userSiteResources)
.values( .values(
userIds.map((userId) => ({ userIds.map((userId) => ({ userId, siteResourceId }))
userId,
siteResourceId
}))
); );
} }
}
// Get all admin role IDs for this org to exclude from deletion // Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx const adminRoles = await trx
@@ -714,23 +721,29 @@ export async function updatePrivateResources(
} }
if (resourceData.users.length > 0) { if (resourceData.users.length > 0) {
const userIds = await resolveOrgUserIds( // get userIds from username
trx, const usersToUpdate = await trx
orgId, .select()
resourceData.users .from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId)
)
); );
if (userIds.length > 0) { const userIds = usersToUpdate.map((user) => user.user.userId);
await trx await trx
.insert(userSiteResources) .insert(userSiteResources)
.values( .values(
userIds.map((userId) => ({ userIds.map((userId) => ({ userId, siteResourceId }))
userId,
siteResourceId
}))
); );
} }
}
if (resourceData.machines.length > 0) { if (resourceData.machines.length > 0) {
// get clientIds from niceIds // get clientIds from niceIds
+27 -8
View File
@@ -46,12 +46,11 @@ import { encrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers"; import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm"; import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUserByIdentifier } from "./findOrgUser";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders"; import { syncInferenceAiConfig } from "./aiProviders";
@@ -1564,19 +1563,29 @@ async function syncUserResources(
.where(eq(userResources.resourceId, resourceId)); .where(eq(userResources.resourceId, resourceId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username); const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) { if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`); throw new Error(`User not found: ${username} in org ${orgId}`);
} }
const existingUserResource = existingUserResources.find( const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.userId (rr) => rr.userId === user.user.userId
); );
if (!existingUserResource) { if (!existingUserResource) {
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: user.userId, userId: user.user.userId,
resourceId: resourceId resourceId: resourceId
}); });
} }
@@ -1946,19 +1955,29 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId)); .where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username); const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) { if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`); throw new Error(`User not found: ${username} in org ${orgId}`);
} }
const existingUserPolicy = existingUserPoliciesList.find( const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.userId (up) => up.userId === user.user.userId
); );
if (!existingUserPolicy) { if (!existingUserPolicy) {
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
+26 -7
View File
@@ -13,7 +13,7 @@ import {
userPolicies, userPolicies,
users users
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and, or } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types"; import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
@@ -22,7 +22,6 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUserByIdentifier } from "./findOrgUser";
export type ResourcePoliciesResults = { export type ResourcePoliciesResults = {
resourcePolicyId: number; resourcePolicyId: number;
@@ -467,7 +466,17 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId)); .where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username); const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) { if (!user) {
logger.warn( logger.warn(
@@ -477,12 +486,12 @@ async function syncUserPolicies(
} }
const alreadyExists = existingUserPolicies.some( const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.userId (up) => up.userId === user.user.userId
); );
if (!alreadyExists) { if (!alreadyExists) {
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
@@ -527,7 +536,17 @@ async function addUserPolicies(
trx: Transaction trx: Transaction
) { ) {
for (const username of ssoUsers) { for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username); const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) { if (!user) {
logger.warn( logger.warn(
@@ -537,7 +556,7 @@ async function addUserPolicies(
} }
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
+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;
} }
@@ -88,27 +88,7 @@ export const queryAccessAuditLogsQuery = z.object({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()), .pipe(z.int().nonnegative())
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
}); });
export const queryAccessAuditLogsParams = z.object({ export const queryAccessAuditLogsParams = z.object({
@@ -154,9 +134,6 @@ function getWhere(data: Q) {
data.type ? eq(accessAuditLog.type, data.type) : undefined, data.type ? eq(accessAuditLog.type, data.type) : undefined,
data.action !== undefined data.action !== undefined
? eq(accessAuditLog.action, data.action) ? eq(accessAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(accessAuditLog.ip, data.ip)
: undefined : undefined
); );
} }
+4 -2
View File
@@ -16,10 +16,12 @@ import {
handleRemoteExitNodePingMessage handleRemoteExitNodePingMessage
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { handleConnectionLogMessage } from "#private/routers/newt"; import {
handleConnectionLogMessage,
} from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage "newt/access-log": handleConnectionLogMessage,
}; };
@@ -81,27 +81,7 @@ export const queryAccessAuditLogsQuery = z.strictObject({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()), .pipe(z.int().nonnegative())
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
}); });
export const queryRequestAuditLogsParams = z.object({ export const queryRequestAuditLogsParams = z.object({
@@ -146,9 +126,6 @@ function getWhere(data: Q) {
data.path ? eq(requestAuditLog.path, data.path) : undefined, data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined data.action !== undefined
? eq(requestAuditLog.action, data.action) ? eq(requestAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(requestAuditLog.ip, data.ip)
: undefined : undefined
); );
} }
+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);
+4 -9
View File
@@ -533,23 +533,18 @@ export async function startAuthentication(
// If email is provided, get security keys for that specific user // If email is provided, get security keys for that specific user
if (email) { if (email) {
const matchingUsers = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where( .where(eq(users.email, email))
and( .limit(1);
eq(users.email, email.toLowerCase()),
eq(users.type, UserType.Internal)
)
);
if (matchingUsers.length !== 1) { if (!user || user.type !== UserType.Internal) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials") createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
); );
} }
const user = matchingUsers[0];
userId = user.userId; userId = user.userId;
const userSecurityKeys = await db const userSecurityKeys = await db
+29 -40
View File
@@ -42,62 +42,54 @@ export async function setServerAdmin(
const { email, password, setupToken } = parsedBody.data; const { email, password, setupToken } = parsedBody.data;
const passwordHash = await hashPassword(password); // Validate setup token
const userId = generateId(15); const [validToken] = await db
.select()
await db.transaction(async (trx) => { .from(setupTokens)
const consumed = await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where( .where(
and( and(
eq(setupTokens.token, setupToken), eq(setupTokens.token, setupToken),
eq(setupTokens.used, false) eq(setupTokens.used, false)
) )
) );
.returning({ tokenId: setupTokens.tokenId });
if (!consumed.length) { if (!validToken) {
throw createHttpError( return next(
createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Invalid setup token" "Invalid or expired setup token"
)
); );
} }
const [existingAdmin] = await trx const [existing] = await db
.select({ userId: users.userId }) .select()
.from(users) .from(users)
.where(eq(users.serverAdmin, true)) .where(eq(users.serverAdmin, true));
.limit(1);
if (existingAdmin) { if (existing) {
throw createHttpError( return next(
createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Server admin already exists" "Server admin already exists"
)
); );
} }
const [existingUser] = await trx const passwordHash = await hashPassword(password);
.select({ userId: users.userId }) const userId = generateId(15);
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser) { await db.transaction(async (trx) => {
throw createHttpError( // Mark the token as used
HttpCode.BAD_REQUEST, await trx
"A user with that email address already exists" .update(setupTokens)
); .set({
} used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
// Create the server admin user
await trx.insert(users).values({ await trx.insert(users).values({
userId: userId, userId: userId,
email: email, email: email,
@@ -119,9 +111,6 @@ export async function setServerAdmin(
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (e) { } catch (e) {
if (createHttpError.isHttpError(e)) {
return next(e);
}
logger.error(e); logger.error(e);
return next( return next(
createHttpError( createHttpError(
+1 -1
View File
@@ -48,7 +48,7 @@ export async function validateSetupToken(
return response<ValidateSetupTokenResponse>(res, { return response<ValidateSetupTokenResponse>(res, {
data: { data: {
valid: false, valid: false,
message: "Invalid setup token" message: "Invalid or expired setup token"
}, },
success: true, success: true,
error: false, error: false,
+6 -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(
@@ -1378,12 +1384,6 @@ if (build !== "saas") {
user.adminGeneratePasswordResetCode user.adminGeneratePasswordResetCode
); );
authenticated.post(
"/user/:userId/server-admin",
verifyUserIsServerAdmin,
user.adminSetServerAdmin
);
authenticated.delete( authenticated.delete(
"/user/:userId", "/user/:userId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
+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
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -196,7 +196,7 @@ export async function adminListUsers(
} }
} }
const conditions: Array<SQL<unknown> | undefined> = []; const conditions = [eq(users.serverAdmin, false)];
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
-142
View File
@@ -1,142 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const setServerAdminParamsSchema = z.strictObject({
userId: z.string()
});
const setServerAdminBodySchema = z.strictObject({
serverAdmin: z.boolean()
});
export type AdminSetServerAdminResponse = {
userId: string;
serverAdmin: boolean;
};
const AdminSetServerAdminResponseDataSchema = z.object({
userId: z.string(),
serverAdmin: z.boolean()
});
registry.registerPath({
method: "post",
path: "/user/{userId}/server-admin",
description: "Promote or demote a user's server admin status (server admin).",
tags: [OpenAPITags.User],
request: {
params: setServerAdminParamsSchema,
body: {
content: {
"application/json": {
schema: setServerAdminBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminSetServerAdminResponseDataSchema
)
}
}
}
}
});
export async function adminSetServerAdmin(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setServerAdminParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId } = parsedParams.data;
const { serverAdmin } = parsedBody.data;
const [existingUser] = await db
.select({
userId: users.userId,
serverAdmin: users.serverAdmin
})
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!existingUser) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (!serverAdmin && req.user?.userId === userId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You cannot remove your own server admin status"
)
);
}
if (existingUser.serverAdmin !== serverAdmin) {
logger.info(
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
);
await db
.update(users)
.set({ serverAdmin })
.where(eq(users.userId, userId));
}
return response<AdminSetServerAdminResponse>(res, {
data: {
userId: existingUser.userId,
serverAdmin
},
success: true,
error: false,
message: serverAdmin
? "User promoted to server admin successfully"
: "User demoted from server admin successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-1
View File
@@ -11,7 +11,6 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./adminGeneratePasswordResetCode"; export * from "./adminGeneratePasswordResetCode";
export * from "./adminSetServerAdmin";
export * from "./listInvitations"; export * from "./listInvitations";
export * from "./removeInvitation"; export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
+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(
@@ -46,7 +46,6 @@ import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
@@ -76,12 +75,6 @@ export default function GeneralPage() {
>([createMappingBuilderRule()]); >([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState(""); const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
const [pendingPayload, setPendingPayload] = useState<Record<
string,
unknown
> | null>(null);
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState( const [redirectUrl, setRedirectUrl] = useState(
@@ -191,9 +184,6 @@ export default function GeneralPage() {
const data = res.data.data; const data = res.data.data;
const roleMapping = data.idpOrg.roleMapping; const roleMapping = data.idpOrg.roleMapping;
const idpVariant = data.idpOidcConfig?.variant || "oidc"; const idpVariant = data.idpOidcConfig?.variant || "oidc";
setOriginalIdentifierPath(
data.idpOidcConfig?.identifierPath ?? "sub"
);
setRedirectUrl(res.data.data.redirectUrl); setRedirectUrl(res.data.data.redirectUrl);
// Set the variant // Set the variant
@@ -388,56 +378,18 @@ export default function GeneralPage() {
}; };
} }
const nextIdentifierPath = const res = await api.post(
variant === "oidc" `/org/${orgId}/idp/${idpId}/oidc`,
? (data as OidcFormValues).identifierPath payload
: undefined; );
if (
typeof nextIdentifierPath === "string" &&
nextIdentifierPath !== originalIdentifierPath
) {
setPendingPayload(payload);
setIdentifierConfirmOpen(true);
return;
}
await persistIdp(payload);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
async function persistIdp(payload: Record<string, unknown>) {
const res = await api.post(`/org/${orgId}/idp/${idpId}/oidc`, payload);
if (res.status === 200) { if (res.status === 200) {
if (typeof payload.identifierPath === "string") {
setOriginalIdentifierPath(payload.identifierPath);
}
toast({ toast({
title: t("success"), title: t("success"),
description: t("idpUpdatedDescription") description: t("idpUpdatedDescription")
}); });
router.refresh(); router.refresh();
} }
}
async function confirmIdentifierChange() {
if (!pendingPayload) {
return;
}
setLoading(true);
try {
await persistIdp(pendingPayload);
setPendingPayload(null);
} catch (e) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
@@ -455,16 +407,6 @@ export default function GeneralPage() {
return ( return (
<> <>
<IdpIdentifierChangeDialog
open={identifierConfirmOpen}
setOpen={(open) => {
setIdentifierConfirmOpen(open);
if (!open) {
setPendingPayload(null);
}
}}
onConfirm={confirmIdentifierChange}
/>
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
+11 -44
View File
@@ -12,7 +12,6 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react"; import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build"; import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -27,7 +26,6 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { logQueries } from "@app/lib/queries"; import { logQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
@@ -47,14 +45,12 @@ export default function GeneralPage() {
resourceId?: string; resourceId?: string;
location?: string; location?: string;
actor?: string; actor?: string;
ip?: string[];
}>({ }>({
action: searchParams.get("action") || undefined, action: searchParams.get("action") || undefined,
type: searchParams.get("type") || undefined, type: searchParams.get("type") || undefined,
resourceId: searchParams.get("resourceId") || undefined, resourceId: searchParams.get("resourceId") || undefined,
location: searchParams.get("location") || undefined, location: searchParams.get("location") || undefined,
actor: searchParams.get("actor") || undefined, actor: searchParams.get("actor") || undefined
ip: searchParams.getAll("ip") || undefined
}); });
const [currentPage, setCurrentPage] = useState<number>(0); const [currentPage, setCurrentPage] = useState<number>(0);
@@ -180,7 +176,7 @@ export default function GeneralPage() {
const handleFilterChange = ( const handleFilterChange = (
filterType: keyof typeof filters, filterType: keyof typeof filters,
value: string | string[] | undefined value: string | undefined
) => { ) => {
const newFilters = { ...filters, [filterType]: value }; const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters); setFilters(newFilters);
@@ -198,13 +194,10 @@ export default function GeneralPage() {
) => { ) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => { Object.entries(newFilters).forEach(([key, value]) => {
params.delete(key); if (value) {
if (typeof value === "string") {
params.set(key, value); params.set(key, value);
} else if (typeof value !== "undefined" && "length" in value) { } else {
for (const element of value) { params.delete(key);
params.append(key, element);
}
} }
}); });
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
@@ -212,7 +205,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
const { ip, ...restFilters } = filters;
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString() ? new Date(dateRange.startDate.date).toISOString()
@@ -220,20 +212,13 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString() ? new Date(dateRange.endDate.date).toISOString()
: undefined, : undefined,
...restFilters ...filters
}; };
// axios serializes arrays as `ip[]=…`, which express's query const response = await api.get(`/org/${orgId}/logs/access/export`, {
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/access/export?${sp.toString()}`,
{
responseType: "blob", responseType: "blob",
params params
} });
);
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a"); const link = document.createElement("a");
@@ -312,24 +297,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: () => ( header: () => <span className="px-2">{t("ip")}</span>,
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => { cell: ({ row }) => {
return row.original.ip ? ( return row.original.ip ? (
row.original.ip row.original.ip
@@ -347,7 +315,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
label: `${location} ${countryCodeToFlagEmoji(location)}` label: location
}) })
)} )}
label={t("location")} label={t("location")}
@@ -366,8 +334,7 @@ export default function GeneralPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{row.original.location ? ( {row.original.location ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{row.original.location}{" "} {row.original.location}
{countryCodeToFlagEmoji(row.original.location)}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
+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 =
+10 -40
View File
@@ -23,8 +23,6 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
@@ -49,7 +47,6 @@ export default function GeneralPage() {
method?: string; method?: string;
reason?: string; reason?: string;
path?: string; path?: string;
ip?: string[];
}>({ }>({
action: searchParams.get("action") || undefined, action: searchParams.get("action") || undefined,
host: searchParams.get("host") || undefined, host: searchParams.get("host") || undefined,
@@ -58,8 +55,7 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined, actor: searchParams.get("actor") || undefined,
method: searchParams.get("method") || undefined, method: searchParams.get("method") || undefined,
reason: searchParams.get("reason") || undefined, reason: searchParams.get("reason") || undefined,
path: searchParams.get("path") || undefined, path: searchParams.get("path") || undefined
ip: searchParams.getAll("ip") || undefined
}); });
const getDefaultDateRange = () => { const getDefaultDateRange = () => {
@@ -183,7 +179,7 @@ export default function GeneralPage() {
const handleFilterChange = ( const handleFilterChange = (
filterType: keyof typeof filters, filterType: keyof typeof filters,
value: string | string[] | undefined value: string | undefined
) => { ) => {
const newFilters = { ...filters, [filterType]: value }; const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters); setFilters(newFilters);
@@ -201,13 +197,10 @@ export default function GeneralPage() {
) => { ) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => { Object.entries(newFilters).forEach(([key, value]) => {
params.delete(key); if (value) {
if (typeof value === "string") {
params.set(key, value); params.set(key, value);
} else if (typeof value !== "undefined" && "length" in value) { } else {
for (const element of value) { params.delete(key);
params.append(key, element);
}
} }
}); });
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
@@ -216,7 +209,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
// Prepare query params for export // Prepare query params for export
const { ip, ...restFilters } = filters;
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString() ? new Date(dateRange.startDate.date).toISOString()
@@ -224,15 +216,11 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString() ? new Date(dateRange.endDate.date).toISOString()
: undefined, : undefined,
...restFilters ...filters
}; };
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get( const response = await api.get(
`/org/${orgId}/logs/request/export?${sp.toString()}`, `/org/${orgId}/logs/request/export`,
{ {
responseType: "blob", responseType: "blob",
params params
@@ -363,24 +351,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: ({ column }) => ( header: ({ column }) => <span className="px-2">{t("ip")}</span>,
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => { cell: ({ row }) => {
return row.original.ip ? ( return row.original.ip ? (
row.original.ip row.original.ip
@@ -398,7 +369,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
label: `${location} ${countryCodeToFlagEmoji(location)}` label: location
}) })
)} )}
selectedValue={filters.location} selectedValue={filters.location}
@@ -418,8 +389,7 @@ export default function GeneralPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{row.original.location ? ( {row.original.location ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{row.original.location}{" "} {row.original.location}
{countryCodeToFlagEmoji(row.original.location)}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@@ -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)) {
@@ -41,7 +41,6 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import IdpTypeBadge from "@app/components/IdpTypeBadge"; import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function GeneralPage() { export default function GeneralPage() {
@@ -52,12 +51,6 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
const [pendingPayload, setPendingPayload] = useState<Record<
string,
unknown
> | null>(null);
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const t = useTranslations(); const t = useTranslations();
@@ -148,9 +141,6 @@ export default function GeneralPage() {
| "google" | "google"
| "azure") || "oidc"; | "azure") || "oidc";
setVariant(idpVariant); setVariant(idpVariant);
setOriginalIdentifierPath(
data.idpOidcConfig?.identifierPath ?? "sub"
);
let tenantId = ""; let tenantId = "";
if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) { if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
@@ -268,56 +258,15 @@ export default function GeneralPage() {
}; };
} }
const nextIdentifierPath =
variant === "oidc"
? (data as OidcFormValues).identifierPath
: undefined;
if (
typeof nextIdentifierPath === "string" &&
nextIdentifierPath !== originalIdentifierPath
) {
setPendingPayload(payload);
setIdentifierConfirmOpen(true);
return;
}
await persistIdp(payload);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
async function persistIdp(payload: Record<string, unknown>) {
const res = await api.post(`/idp/${idpId}/oidc`, payload); const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) { if (res.status === 200) {
if (typeof payload.identifierPath === "string") {
setOriginalIdentifierPath(payload.identifierPath);
}
toast({ toast({
title: t("success"), title: t("success"),
description: t("idpUpdatedDescription") description: t("idpUpdatedDescription")
}); });
router.refresh(); router.refresh();
} }
}
async function confirmIdentifierChange() {
if (!pendingPayload) {
return;
}
setLoading(true);
try {
await persistIdp(pendingPayload);
setPendingPayload(null);
} catch (e) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
@@ -335,16 +284,6 @@ export default function GeneralPage() {
return ( return (
<> <>
<IdpIdentifierChangeDialog
open={identifierConfirmOpen}
setOpen={(open) => {
setIdentifierConfirmOpen(open);
if (!open) {
setPendingPayload(null);
}
}}
onConfirm={confirmIdentifierChange}
/>
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
+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
}}
/>
</>
);
}
-3
View File
@@ -81,9 +81,6 @@ export default async function UsersPage(props: AdminUsersPageProps) {
}; };
}); });
console.log({
userRows
});
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
+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",
+2 -185
View File
@@ -19,8 +19,7 @@ import {
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal, MoreHorizontal
ShieldUserIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -44,14 +43,6 @@ import {
CredenzaClose CredenzaClose
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "./ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { useUserContext } from "@app/hooks/useUserContext";
export type GlobalUserRow = { export type GlobalUserRow = {
id: string; id: string;
@@ -99,11 +90,6 @@ export default function UsersTable({
const [passwordResetCodeData, setPasswordResetCodeData] = const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null); useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false); const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
const [demoting, setDemoting] = useState<GlobalUserRow | null>(null);
const user = useUserContext();
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const { const {
@@ -198,54 +184,6 @@ export default function UsersTable({
} }
}; };
const setServerAdmin = async (
targetUser: GlobalUserRow,
serverAdmin: boolean
) => {
const successTitleKey = serverAdmin
? "promoteServerAdminSuccess"
: "demoteServerAdminSuccess";
const successDescriptionKey = serverAdmin
? "promoteServerAdminSuccessDescription"
: "demoteServerAdminSuccessDescription";
const errorKey = serverAdmin
? "promoteServerAdminError"
: "demoteServerAdminError";
try {
await api.post(`/user/${targetUser.id}/server-admin`, {
serverAdmin
});
toast({
title: t(successTitleKey),
description: t(successDescriptionKey, {
selectedUser: getUserDisplayName({
email: targetUser.email,
name: targetUser.name,
username: targetUser.username
})
})
});
startTransition(() => {
router.refresh();
});
} catch (e) {
console.error(t(errorKey), e);
toast({
variant: "destructive",
title: t(errorKey),
description: formatAxiosError(e, t(errorKey))
});
} finally {
setIsPromoteModalOpen(false);
setPromoting(null);
setIsDemoteModalOpen(false);
setDemoting(null);
}
};
function toggleSort(column: string) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);
filter({ filter({
@@ -297,35 +235,7 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, }
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.username}{" "}
{row.original.id === user.user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.serverAdmin && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ShieldUserIcon className="text-primary size-5 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* <Badge>{t("serverAdmin")}</Badge> */}
</>
)}
</span>
)
}, },
{ {
accessorKey: "email", accessorKey: "email",
@@ -459,32 +369,11 @@ export default function UsersTable({
{t("generatePasswordResetCode")} {t("generatePasswordResetCode")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{!r.serverAdmin && (
<DropdownMenuItem
onClick={() => {
setPromoting(r);
setIsPromoteModalOpen(true);
}}
>
{t("promoteServerAdmin")}
</DropdownMenuItem>
)}
{r.serverAdmin && r.id !== user.user.userId && (
<DropdownMenuItem
onClick={() => {
setDemoting(r);
setIsDemoteModalOpen(true);
}}
>
{t("demoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelected(r); setSelected(r);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
className="text-red-400"
> >
{t("delete")} {t("delete")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -546,78 +435,6 @@ export default function UsersTable({
/> />
)} )}
{promoting && (
<ConfirmDeleteDialog
open={isPromoteModalOpen}
setOpen={(val) => {
setIsPromoteModalOpen(val);
if (!val) {
setPromoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("promoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})
})}
</p>
<p>{t("promoteServerAdminMessage")}</p>
</div>
}
buttonText={t("promoteServerAdminConfirm")}
onConfirm={async () => setServerAdmin(promoting, true)}
string={getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})}
warningText={t("promoteServerAdminWarning")}
title={t("promoteServerAdminTitle")}
/>
)}
{demoting && (
<ConfirmDeleteDialog
open={isDemoteModalOpen}
setOpen={(val) => {
setIsDemoteModalOpen(val);
if (!val) {
setDemoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("demoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})
})}
</p>
<p>{t("demoteServerAdminMessage")}</p>
</div>
}
buttonText={t("demoteServerAdminConfirm")}
onConfirm={async () => setServerAdmin(demoting, false)}
string={getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})}
warningText={t("demoteServerAdminWarning")}
title={t("demoteServerAdminTitle")}
/>
)}
<ControlledDataTable <ControlledDataTable
columns={columns} columns={columns}
rows={users} rows={users}
+3 -5
View File
@@ -21,7 +21,7 @@ import { useTranslations } from "next-intl";
interface FilterOption { interface FilterOption {
value: string; value: string;
label: React.ReactNode; label: string;
} }
interface ColumnFilterButtonProps { interface ColumnFilterButtonProps {
@@ -32,7 +32,6 @@ interface ColumnFilterButtonProps {
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
label: string; label: string;
allowArbitraryValues?: boolean;
} }
export function ColumnFilterButton({ export function ColumnFilterButton({
@@ -42,8 +41,7 @@ export function ColumnFilterButton({
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
emptyMessage = "No options found", emptyMessage = "No options found",
className, className,
label, label
allowArbitraryValues
}: ColumnFilterButtonProps) { }: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -103,7 +101,7 @@ export function ColumnFilterButton({
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.value} value={option.label}
onSelect={() => { onSelect={() => {
onValueChange( onValueChange(
selectedValue === option.value selectedValue === option.value
+4 -24
View File
@@ -35,7 +35,6 @@ type ColumnMultiFilterButtonProps = {
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
label: string; label: string;
allowArbitraryValues?: boolean;
}; };
export function ColumnMultiFilterButton({ export function ColumnMultiFilterButton({
@@ -45,26 +44,11 @@ export function ColumnMultiFilterButton({
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
emptyMessage = "No options found", emptyMessage = "No options found",
className, className,
label, label
allowArbitraryValues
}: ColumnMultiFilterButtonProps) { }: ColumnMultiFilterButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const t = useTranslations(); const t = useTranslations();
const visibleOptions = useMemo<FilterOption[]>(() => {
const newOptions = [...options];
if (allowArbitraryValues && searchQuery.trim().length > 0) {
newOptions.push({
label: searchQuery,
value: searchQuery
});
}
return newOptions;
}, [options, allowArbitraryValues, searchQuery]);
const selectedSet = useMemo( const selectedSet = useMemo(
() => new Set(selectedValues), () => new Set(selectedValues),
[selectedValues] [selectedValues]
@@ -80,7 +64,7 @@ export function ColumnMultiFilterButton({
selectedValues[0] selectedValues[0]
); );
} }
return t("multiSelectFilterCount", { return t("accessUsersRoleFilterCount", {
count: selectedValues.length count: selectedValues.length
}); });
}, [selectedValues, options, t]); }, [selectedValues, options, t]);
@@ -124,11 +108,7 @@ export function ColumnMultiFilterButton({
align="start" align="start"
> >
<Command> <Command>
<CommandInput <CommandInput placeholder={searchPlaceholder} />
placeholder={searchPlaceholder}
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList> <CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty> <CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup> <CommandGroup>
@@ -143,7 +123,7 @@ export function ColumnMultiFilterButton({
{t("accessFilterClear")} {t("accessFilterClear")}
</CommandItem> </CommandItem>
)} )}
{visibleOptions.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} value={option.label}
@@ -1,35 +0,0 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useTranslations } from "next-intl";
type IdpIdentifierChangeDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
onConfirm: () => Promise<void>;
};
export default function IdpIdentifierChangeDialog({
open,
setOpen,
onConfirm
}: IdpIdentifierChangeDialogProps) {
const t = useTranslations();
return (
<ConfirmDeleteDialog
open={open}
setOpen={setOpen}
dialog={
<div className="space-y-2">
<p>{t("idpIdentifierChangeDescription")}</p>
</div>
}
buttonText={t("saveGeneralSettings")}
onConfirm={onConfirm}
string={t("idpIdentifierChangeConfirmMessage")}
title={t("idpIdentifierChangeTitle")}
warningText={t("idpIdentifierChangeWarningText")}
/>
);
}
+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";
@@ -155,13 +155,13 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
} }
/> />
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out"> <CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup <CommandGroup
heading={t("commandActionModeInfo")} heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm" className="[&_[cmdk-group-heading]]:text-sm"
/> />
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
{!isActionMode && {!isActionMode &&
navigationGroups.map((group, groupIndex) => ( navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}> <React.Fragment key={group.heading}>
-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;
}
+22 -30
View File
@@ -1,8 +1,3 @@
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -10,21 +5,15 @@ import {
StatusHistoryResponse, StatusHistoryResponse,
type BatchedStatusHistoryResponse type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory"; } from "@server/lib/statusHistory";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type { import type {
QueryRequestAnalyticsResponse,
QueryAiUsageFilterOptionsResponse, QueryAiUsageFilterOptionsResponse,
QueryAiUsageOverviewResponse, QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse, QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse, QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse, QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse, QueryAiUsageVirtualApiKeysResponse
QueryRequestAnalyticsResponse
} from "@server/routers/auditLogs"; } from "@server/routers/auditLogs";
import type { import type {
QueryAccessAuditLogResponse, QueryAccessAuditLogResponse,
@@ -45,7 +34,6 @@ import type {
import type { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type { import type {
LauncherResource, LauncherResource,
ListLauncherGroupsResponse, ListLauncherGroupsResponse,
@@ -55,8 +43,9 @@ import type {
ListLauncherSitesResponse, ListLauncherSitesResponse,
ListLauncherViewsResponse ListLauncherViewsResponse
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { import type {
GetResourcePoliciesResponse, GetResourcePoliciesResponse,
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
@@ -70,6 +59,7 @@ import type {
import type { GetResourceResponse } from "@server/routers/resource/getResource"; import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { ListRolesResponse } from "@server/routers/role"; import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site"; import type { ListSitesResponse } from "@server/routers/site";
import type { import type {
@@ -81,8 +71,18 @@ import type {
} from "@server/routers/siteResource"; } from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target"; import type { ListTargetsResponse } from "@server/routers/target";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { ListUsersResponse } from "@server/routers/user"; import type { ListUsersResponse } from "@server/routers/user";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type ResponseT from "@server/types/Response"; import type ResponseT from "@server/types/Response";
import { import {
infiniteQueryOptions, infiniteQueryOptions,
@@ -1000,8 +1000,7 @@ export const httpLogsFiltersSchema = z.object({
actor: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined),
method: z.string().optional().catch(undefined), method: z.string().optional().catch(undefined),
reason: z.string().optional().catch(undefined), reason: z.string().optional().catch(undefined),
path: z.string().optional().catch(undefined), path: z.string().optional().catch(undefined)
ip: z.array(z.string()).optional().catch(undefined)
}); });
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>; export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
@@ -1027,8 +1026,7 @@ export const accessLogsFiltersSchema = z.object({
action: z.string().optional().catch(undefined), action: z.string().optional().catch(undefined),
location: z.string().optional().catch(undefined), location: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined),
type: z.string().optional().catch(undefined), type: z.string().optional().catch(undefined)
ip: z.array(z.string()).optional().catch(undefined)
}); });
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>; export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
@@ -1141,13 +1139,10 @@ export const logQueries = {
queryOptions({ queryOptions({
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const, queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const { page, pageSize, ip, ...rest } = filters; const { page, pageSize, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryRequestAuditLogResponse> AxiosResponse<QueryRequestAuditLogResponse>
>(`/org/${orgId}/logs/request?${sp.toString()}`, { >(`/org/${orgId}/logs/request`, {
params: { params: {
...rest, ...rest,
limit: pageSize, limit: pageSize,
@@ -1169,13 +1164,10 @@ export const logQueries = {
queryOptions({ queryOptions({
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const, queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const { page, pageSize, ip, ...rest } = filters; const { page, pageSize, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryAccessAuditLogResponse> AxiosResponse<QueryAccessAuditLogResponse>
>(`/org/${orgId}/logs/access?${sp.toString()}`, { >(`/org/${orgId}/logs/access`, {
params: { params: {
...rest, ...rest,
limit: pageSize, limit: pageSize,
+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;
} }