Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot] 9b7f59b90c Bump next and @react-email/ui
Bumps [next](https://github.com/vercel/next.js) to 16.3.3 and updates ancestor dependency [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui). These dependencies need to be updated together.


Updates `next` from 16.3.0 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.0...v16.3.3)

Updates `@react-email/ui` from 6.9.3 to 6.9.5
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.9.5/packages/ui)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: indirect
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 05:24:00 +00:00
Owen Schwartz 17375348b0 Merge pull request #3702 from fosrl/dev
1.22.2
2026-09-04 17:18:42 -04:00
Owen Schwartz e7fdbf9e85 Merge pull request #3699 from fosrl/dev
1.22.1-s.1
2026-09-04 10:38:39 -04:00
Milo Schwartz cddb5ecc3d Merge pull request #3691 from fosrl/dev
update readme
2026-09-03 16:19:47 -04:00
65 changed files with 1229 additions and 3058 deletions
+14 -42
View File
@@ -80,7 +80,7 @@
"siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector anywhere you can run a binary or container to establish the connection.",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
"sitesBannerButtonText": "Install Site Connector",
"approvalsBannerTitle": "Approve or Deny Device Access",
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
@@ -226,9 +226,9 @@
"never": "Never",
"shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible via a proxy",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"publicResourcesBannerTitle": "Web-based Public Access",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet, like a website or API, and include identity and context-aware access policies. Unlike private resources, they do not require any client-side software to access.",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -465,8 +465,6 @@
"apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys",
"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",
"provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
@@ -522,12 +520,12 @@
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "Learn More",
"apiKeysSettings": "{apiKeyName} Settings",
"userTitle": "Manage Users",
"userDescription": "View and manage all users in this instance",
"userTitle": "Manage All Users",
"userDescription": "View and manage all users in the system",
"userAbount": "About User Management",
"userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.",
"userServer": "Server Users",
"userSearch": "Search users...",
"userSearch": "Search server users...",
"userErrorDelete": "Error deleting user",
"userDeleteConfirm": "Confirm Delete User",
"userDeleteServer": "Delete User from Server",
@@ -596,7 +594,7 @@
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
"invite": "Invitations",
"inviteRegenerate": "Regenerate Invitation",
"inviteRegenerateDescription": "Create a new invite link for this user. The previous invitation will be revoked.",
"inviteRegenerateDescription": "Revoke previous invitation and create a new one",
"inviteRemove": "Remove Invitation",
"inviteRemoveError": "Failed to remove invitation",
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
@@ -676,11 +674,11 @@
"accessUserCreateDescription": "Follow the steps below to create a new user",
"userSeeAll": "See All Users",
"userTypeTitle": "User Type",
"userTypeDescription": "Select the identity provider to use for this user",
"userTypeDescription": "Determine how you want to create the user",
"userSettings": "User Information",
"userSettingsDescription": "Enter the general details for the new user",
"userSettingsDescription": "Enter the details for the new user",
"inviteEmailSent": "Send invite email to user",
"inviteValid": "Invite Valid For",
"inviteValid": "Invite Valid For (days)",
"selectDuration": "Select duration",
"selectResource": "Select Resource",
"filterByResource": "Filter By Resource",
@@ -718,7 +716,6 @@
"nameOptional": "Name (Optional)",
"accessControls": "Access Controls",
"userDescription2": "Manage the settings on this user",
"userGeneralSettingsDescription": "Manage this user's roles and settings in the organization",
"accessRoleErrorAdd": "Failed to add user to role",
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved",
@@ -1217,7 +1214,7 @@
"orgPoliciesEdit": "Edit Organization Policy",
"org": "Organization",
"orgSelect": "Select organization",
"orgSearch": "Search organizations...",
"orgSearch": "Search org",
"orgNotFound": "No org found.",
"roleMappingPathOptional": "Role Mapping Path (Optional)",
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
@@ -1356,7 +1353,7 @@
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "No labels found.",
"labelsEmptyCreateHint": "Start typing above to create a label.",
"labelSearch": "Search labels...",
"labelSearch": "Search labels",
"labelSearchOrCreate": "Search or create a label",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
@@ -1427,24 +1424,6 @@
"logoutError": "Error logging out",
"signingAs": "Signed in as",
"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 the highest privileges and can manage the server.",
"promoteServerAdminWarning": "This can be undone at any time by demoting the user.",
"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 all server admin privileges.",
"demoteServerAdminWarning": "This can be undone at any time by promoting the user.",
"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",
"otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor",
@@ -2116,10 +2095,9 @@
"resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
"sidebarAllUsers": "Users",
"sidebarAllUsers": "All Users",
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients",
@@ -3996,12 +3974,11 @@
"disconnected": "Disconnected",
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
"approvalsEmptyStateHowToTitle": "How to Enable",
"approvalsEmptyStateStep1Title": "Go to Roles",
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
"approvalsEmptyStatePreviewDescription": "When enabled, pending device requests will appear here for review.",
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStateButtonText": "Manage Roles",
"domainErrorTitle": "We are having trouble verifying your domain",
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
@@ -4276,11 +4253,6 @@
"resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
"resourceLauncherResourceDetails": "Resource Details",
"resourceLauncherSitesDescription": "The resource is accessible via the following sites.",
"resourceLauncherViewSiteAsAdmin": "View Site as Admin",
"resourceLauncherFilterBySite": "Filter by Site",
"resourceLauncherSshCommand": "SSH Command",
"resourceLauncherSshCommandDescription": "Use the Pangolin CLI to open an SSH session to this resource.",
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
-5
View File
@@ -34,11 +34,6 @@ const nextConfig: NextConfig = {
source: "/:orgId/settings/resources/client/:path*",
destination: "/:orgId/settings/resources/private/:path*",
permanent: true
},
{
source: "/:orgId/settings/access/users/:userId/access-controls",
destination: "/:orgId/settings/access/users/:userId/general",
permanent: false
}
];
}
+5 -224
View File
@@ -116,7 +116,7 @@
"devDependencies": {
"@dotenvx/dotenvx": "2.23.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.3",
"@react-email/ui": "^6.9.5",
"@tailwindcss/postcss": "4.3.3",
"@tanstack/react-query-devtools": "5.102.8",
"@types/better-sqlite3": "7.6.13",
@@ -5219,233 +5219,14 @@
}
},
"node_modules/@react-email/ui": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/@react-email/ui/-/ui-6.9.3.tgz",
"integrity": "sha512-6lVPkSvsRngyPkzm9w7LeUu60vlTDs/NRSYNV2SrXnjCzDCoK0ZsACK6T521Mkn8Ojft1oYg7k+4BIy1r28OGg==",
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@react-email/ui/-/ui-6.9.5.tgz",
"integrity": "sha512-UvE0yeSQCvdBm9BoEgvZ5vBi8abk02GbRE+4RQjjtxozy66D/O1GbUgK9RdB26+7EqQdOxdwkGLq5CMCiRO96A==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "0.28.1",
"next": "16.3.0"
}
},
"node_modules/@react-email/ui/node_modules/@next/env": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@react-email/ui/node_modules/@next/swc-darwin-arm64": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-darwin-x64": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@react-email/ui/node_modules/next": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/env": "16.3.0",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.5.23",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.0",
"@next/swc-darwin-x64": "16.3.0",
"@next/swc-linux-arm64-gnu": "16.3.0",
"@next/swc-linux-arm64-musl": "16.3.0",
"@next/swc-linux-x64-gnu": "16.3.0",
"@next/swc-linux-x64-musl": "16.3.0",
"@next/swc-win32-arm64-msvc": "16.3.0",
"@next/swc-win32-x64-msvc": "16.3.0",
"sharp": "^0.35.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
"next": "16.3.3"
}
},
"node_modules/@react-types/shared": {
+2 -2
View File
@@ -96,7 +96,6 @@
"jmespath": "0.16.0",
"js-yaml": "5.4.1",
"jsonwebtoken": "9.0.3",
"lru-cache": "11.5.2",
"lucide-react": "1.38.0",
"maxmind": "5.0.7",
"moment": "2.30.1",
@@ -104,6 +103,7 @@
"next-intl": "4.14.1",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "9.1.0",
"oslo": "1.2.1",
"pg": "8.23.0",
@@ -139,7 +139,7 @@
"devDependencies": {
"@dotenvx/dotenvx": "2.23.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.3",
"@react-email/ui": "^6.9.5",
"@tailwindcss/postcss": "4.3.3",
"@tanstack/react-query-devtools": "5.102.8",
"@types/better-sqlite3": "7.6.13",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

After

Width:  |  Height:  |  Size: 800 KiB

+8 -2
View File
@@ -1,7 +1,13 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { createLocalCache } from "@server/lib/createLocalCache";
export const localCache = createLocalCache();
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
// Log cache statistics periodically for monitoring
// setInterval(() => {
-79
View File
@@ -1,79 +0,0 @@
import { LRUCache } from "lru-cache";
const DEFAULT_MAX_KEYS = 10000;
const DEFAULT_TTL_MS = 3600 * 1000;
export type LocalCache = {
get<T>(key: string): T | undefined;
set(key: string, value: unknown, ttlSeconds?: number): boolean;
del(key: string | string[]): number;
has(key: string): boolean;
keys(): string[];
flushAll(): void;
getStats(): { keys: number };
getTtl(key: string): number | undefined;
};
export function createLocalCache(max = DEFAULT_MAX_KEYS): LocalCache {
const lru = new LRUCache<string, {}>({
max,
ttl: DEFAULT_TTL_MS,
updateAgeOnGet: false
});
return {
get<T>(key: string): T | undefined {
return lru.get(key) as T | undefined;
},
set(key: string, value: unknown, ttlSeconds?: number): boolean {
const stored = value as {};
if (ttlSeconds === undefined) {
lru.set(key, stored);
} else if (ttlSeconds === 0) {
lru.set(key, stored, { ttl: 0 });
} else {
lru.set(key, stored, { ttl: ttlSeconds * 1000 });
}
return true;
},
del(key: string | string[]): number {
const keys = Array.isArray(key) ? key : [key];
let deleted = 0;
for (const k of keys) {
if (lru.delete(k)) {
deleted++;
}
}
return deleted;
},
has(key: string): boolean {
return lru.has(key);
},
keys(): string[] {
return [...lru.keys()];
},
flushAll(): void {
lru.clear();
},
getStats(): { keys: number } {
return { keys: lru.size };
},
getTtl(key: string): number | undefined {
if (!lru.has(key)) {
return undefined;
}
const remaining = lru.getRemainingTTL(key);
if (!Number.isFinite(remaining)) {
return 0;
}
return Date.now() + remaining;
}
};
}
+1 -1
View File
@@ -71,7 +71,7 @@ export async function withRetry<T>(
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
+2 -2
View File
@@ -348,8 +348,8 @@ export const configSchema = z
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
subnet_group: z.string().optional().default("100.89.137.0/18"),
block_size: z.number().positive().gt(0).optional().default(22),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
site_block_size: z
.number()
.positive()
+14 -4
View File
@@ -11,11 +11,17 @@
* This file is not licensed under the AGPLv3.
*/
import NodeCache from "node-cache";
import logger from "@server/logger";
import { createLocalCache } from "@server/lib/createLocalCache";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
export const localCache = createLocalCache();
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
// Log cache statistics periodically for monitoring
// setInterval(() => {
@@ -295,11 +301,15 @@ export default cache;
/**
* Regional adaptive cache backed by the in-cluster Redis instance.
* Falls back to a local LRU cache when the regional Redis is unavailable.
* Falls back to a local NodeCache when the regional Redis is unavailable.
* Use this for data that is regional in nature (e.g. status history) so
* reads are served from the same cluster the user is hitting.
*/
const regionalLocalCache = createLocalCache();
const regionalLocalCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache {
private useRedis(): boolean {
+6 -6
View File
@@ -14,7 +14,7 @@
import { db, HostMeta, sites, users } from "@server/db";
import { hostMeta, licenseKey } from "@server/db";
import logger from "@server/logger";
import { createLocalCache } from "@server/lib/createLocalCache";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { count, eq } from "drizzle-orm";
import moment from "moment";
@@ -65,8 +65,8 @@ export class License {
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
private statusCache = createLocalCache();
private licenseKeyCache = createLocalCache();
private statusCache = new NodeCache();
private licenseKeyCache = new NodeCache();
private statusKey = "status";
private serverSecret!: string;
@@ -179,7 +179,7 @@ LQIDAQAB
status.isHostLicensed = false;
// Invalidate all and set new cache (empty)
this.licenseKeyCache.flushAll();
this.statusCache.set(this.statusKey, status, 0);
this.statusCache.set(this.statusKey, status);
return status;
}
@@ -389,7 +389,7 @@ LQIDAQAB
// Invalidate old cache and set new cache
this.licenseKeyCache.flushAll();
for (const [key, value] of newCache.entries()) {
this.licenseKeyCache.set(key, value, 0);
this.licenseKeyCache.set<LicenseKeyCache>(key, value);
}
} catch (error) {
logger.error("Error checking license status:");
@@ -398,7 +398,7 @@ LQIDAQAB
this.checkInProgress = false;
}
this.statusCache.set(this.statusKey, status, 0);
this.statusCache.set(this.statusKey, status);
return status;
}
+1 -6
View File
@@ -2478,12 +2478,7 @@ hybridRouter.post(
destinations: destinations
});
} catch (error) {
if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error);
}
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -1,23 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { EventEmitter } from "events";
export interface ExitNodeOnlineEvent {
exitNodeId: number;
endpoint: string;
}
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
export const exitNodeEvents = new EventEmitter();
@@ -12,27 +12,11 @@
*/
import axios from "axios";
import { db, newts, sites } from "@server/db";
import { db, exitNodes, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
import { sendToClient } from "../ws";
import {
exitNodeEvents,
EXIT_NODE_ONLINE_EVENT,
ExitNodeOnlineEvent
} from "./exitNodeEvents";
exitNodeEvents.on(
EXIT_NODE_ONLINE_EVENT,
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
logger.error("Failed to schedule exit node reconnect", {
error
});
});
}
);
// import { sendToClient } from "#private/routers/ws";
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
@@ -42,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect {
startTime: number;
endpoint: string;
reachableAt: string;
}
// In-memory tracking for this node
@@ -56,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
*/
export async function scheduleExitNodeReconnect(
exitNodeId: number,
endpoint: string
reachableAt: string
): Promise<void> {
logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
);
const entry: PendingReconnect = {
startTime: Date.now(),
endpoint
reachableAt
};
pendingReconnects.set(exitNodeId, entry);
@@ -79,8 +63,8 @@ export async function scheduleExitNodeReconnect(
);
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"endpoint",
endpoint
"reachableAt",
reachableAt
);
}
}
@@ -117,14 +101,14 @@ async function processPendingReconnects(): Promise<void> {
`${REDIS_HASH_PREFIX}${id}`,
"startTime"
);
const endpoint = await redisManager.hget(
const reachableAt = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"endpoint"
"reachableAt"
);
if (startTimeStr && endpoint) {
if (startTimeStr && reachableAt) {
toProcess.set(id, {
startTime: parseInt(startTimeStr, 10),
endpoint
reachableAt
});
}
}
@@ -151,7 +135,7 @@ async function processPendingReconnects(): Promise<void> {
}
// Check if the exit node HTTP endpoint is reachable
const pingUrl = `http://${entry.endpoint}/ping`;
const pingUrl = `${entry.reachableAt}/ping`;
try {
await axios.get(pingUrl, { timeout: 5000 });
} catch {
@@ -166,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
);
await sendReconnectToNewts(exitNodeId);
// await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId);
}
}
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try {
const connectedNewts = await db
.select({ newtId: newts.newtId })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId));
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
// try {
// const connectedNewts = await db
// .select({ newtId: newts.newtId })
// .from(newts)
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
// .where(eq(sites.exitNodeId, exitNodeId));
if (connectedNewts.length === 0) {
logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
);
return;
}
// if (connectedNewts.length === 0) {
// logger.debug(
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
// );
// return;
// }
logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
);
// logger.info(
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
// );
const reconnectMessage = {
type: "newt/wg/reconnect",
data: {}
};
// const reconnectMessage = {
// type: "newt/wg/reconnect",
// data: {}
// };
await Promise.allSettled(
connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage)
)
);
} catch (error) {
logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error }
);
}
}
// await Promise.allSettled(
// connectedNewts.map(({ newtId }) =>
// sendToClient(newtId, reconnectMessage)
// )
// );
// } catch (error) {
// logger.error(
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
// { error }
// );
// }
// }
async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId);
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
/**
* Handles ping messages from clients and responds with pong
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
try {
// Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1);
@@ -55,14 +55,12 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects
if (
currentExitNode &&
!currentExitNode.online &&
currentExitNode.endpoint
) {
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
exitNodeId: remoteExitNode.exitNodeId,
endpoint: currentExitNode.endpoint
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
scheduleExitNodeReconnect(
remoteExitNode.exitNodeId,
currentExitNode.reachableAt
).catch((error) => {
logger.error("Failed to schedule exit node reconnect", { error });
});
}
} catch (error) {
+2 -6
View File
@@ -691,9 +691,7 @@ export async function verifyResourceSession(
);
resourceSession = result?.resourceSession;
if (resourceSession) {
localCache.set(sessionCacheKey, resourceSession, 5);
}
localCache.set(sessionCacheKey, resourceSession, 5);
}
if (resourceSession?.isRequestToken) {
@@ -1123,9 +1121,7 @@ async function allowAccessToken(
resource.resourceId
);
resourceSession = result?.resourceSession;
if (resourceSession) {
localCache.set(sessionCacheKey, resourceSession, 5);
}
localCache.set(sessionCacheKey, resourceSession, 5);
}
if (
-12
View File
@@ -87,12 +87,6 @@ authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
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(
@@ -1384,12 +1378,6 @@ if (build !== "saas") {
user.adminGeneratePasswordResetCode
);
authenticated.post(
"/user/:userId/server-admin",
verifyUserIsServerAdmin,
user.adminSetServerAdmin
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
+87 -183
View File
@@ -522,7 +522,6 @@ async function fetchLabelsForResources(
type SiteGroupRow = {
siteId: number;
name: string;
niceId: string;
type: string;
online: boolean;
itemCount: number;
@@ -557,7 +556,6 @@ async function listSiteGroups(
.select({
siteId: sites.siteId,
name: sites.name,
niceId: sites.niceId,
type: sites.type,
online: sites.online,
itemCount: countDistinct(resources.resourceId)
@@ -578,13 +576,7 @@ async function listSiteGroups(
const publicRows = await publicQuery
.where(and(...publicConditions))
.groupBy(
sites.siteId,
sites.name,
sites.niceId,
sites.type,
sites.online
);
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of publicRows) {
const existing = siteCountMap.get(row.siteId);
@@ -594,7 +586,6 @@ async function listSiteGroups(
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
niceId: row.niceId,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
@@ -621,7 +612,6 @@ async function listSiteGroups(
.select({
siteId: sites.siteId,
name: sites.name,
niceId: sites.niceId,
type: sites.type,
online: sites.online,
itemCount: countDistinct(siteResources.siteResourceId)
@@ -648,13 +638,7 @@ async function listSiteGroups(
const siteRows = await siteResourceQuery
.where(and(...siteConditions))
.groupBy(
sites.siteId,
sites.name,
sites.niceId,
sites.type,
sites.online
);
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of siteRows) {
const existing = siteCountMap.get(row.siteId);
@@ -664,7 +648,6 @@ async function listSiteGroups(
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
niceId: row.niceId,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
@@ -1078,43 +1061,6 @@ export async function listLauncherGroupsForUser(
};
}
function toLauncherSiteInfo(row: {
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
siteType: string | null;
siteOnline: boolean | null;
}): LauncherSiteInfo | null {
if (
row.siteId == null ||
row.siteName == null ||
row.siteNiceId == null ||
row.siteType == null
) {
return null;
}
return {
siteId: row.siteId,
name: row.siteName,
niceId: row.siteNiceId,
type: row.siteType,
online: row.siteOnline ?? undefined
};
}
function pickPrimarySite(
sites: LauncherSiteInfo[],
siteIdFilter?: number
): LauncherSiteInfo | undefined {
if (sites.length === 0) {
return undefined;
}
if (siteIdFilter != null) {
return sites.find((site) => site.siteId === siteIdFilter) ?? sites[0];
}
return sites[0];
}
async function mapPublicResources(
orgId: string,
resourceIds: number[],
@@ -1138,7 +1084,6 @@ async function mapPublicResources(
enabled: resources.enabled,
siteId: sites.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteType: sites.type,
siteOnline: sites.online,
exitNodeEndpoint: exitNodes.endpoint
@@ -1152,65 +1097,56 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved")
eq(resources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
)
);
const byKey = new Map<string, LauncherResource>();
const siteIdsByKey = new Map<string, Set<number>>();
const seen = new Set<string>();
const result: LauncherResource[] = [];
for (const row of rows) {
const key = `public:${row.resourceId}`;
let item = byKey.get(key);
if (!item) {
const access = formatPublicResourceAccess({
mode: row.mode,
fullDomain: row.fullDomain,
ssl: row.ssl,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
exitNodeEndpoint: row.exitNodeEndpoint
});
item = {
launcherResourceKey: key,
resourceType: "public",
resourceId: row.resourceId,
niceId: row.niceId,
name: row.name,
...access,
iconUrl: null,
enabled: row.enabled,
mode: row.mode,
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
sites: []
};
byKey.set(key, item);
siteIdsByKey.set(key, new Set());
}
const site = toLauncherSiteInfo(row);
if (!site) {
if (seen.has(key)) {
continue;
}
seen.add(key);
const seenSiteIds = siteIdsByKey.get(key)!;
if (seenSiteIds.has(site.siteId)) {
continue;
}
seenSiteIds.add(site.siteId);
item.sites.push(site);
const access = formatPublicResourceAccess({
mode: row.mode,
fullDomain: row.fullDomain,
ssl: row.ssl,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
exitNodeEndpoint: row.exitNodeEndpoint
});
result.push({
launcherResourceKey: key,
resourceType: "public",
resourceId: row.resourceId,
niceId: row.niceId,
name: row.name,
...access,
iconUrl: null,
enabled: row.enabled,
mode: row.mode,
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
site:
row.siteId != null
? {
siteId: row.siteId,
name: row.siteName!,
type: row.siteType!,
online: row.siteOnline ?? undefined
}
: undefined
});
}
for (const item of byKey.values()) {
item.sites.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
item.site = pickPrimarySite(item.sites, siteIdFilter);
}
return Array.from(byKey.values());
return result;
}
async function mapSiteResources(
@@ -1239,7 +1175,6 @@ async function mapSiteResources(
enabled: siteResources.enabled,
siteId: sites.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteType: sites.type,
siteOnline: sites.online
})
@@ -1254,69 +1189,59 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
eq(siteResources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
)
);
const byKey = new Map<string, LauncherResource>();
const siteIdsByKey = new Map<string, Set<number>>();
const seen = new Set<string>();
const result: LauncherResource[] = [];
for (const row of rows) {
const key = `site:${row.siteResourceId}`;
let item = byKey.get(key);
if (!item) {
const access = formatSiteResourceAccess({
mode: row.mode,
destination: row.destination,
destinationPort: row.destinationPort,
scheme: row.scheme,
ssl: row.ssl,
fullDomain: row.fullDomain,
alias: row.alias,
aliasAddress: row.aliasAddress
});
item = {
launcherResourceKey: key,
resourceType: "site",
resourceId: row.siteResourceId,
siteResourceId: row.siteResourceId,
niceId: row.niceId,
name: row.name,
...access,
iconUrl: null,
enabled: row.enabled,
mode: row.mode,
labels:
labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
sites: []
};
byKey.set(key, item);
siteIdsByKey.set(key, new Set());
}
const site = toLauncherSiteInfo(row);
if (!site) {
if (seen.has(key)) {
continue;
}
seen.add(key);
const seenSiteIds = siteIdsByKey.get(key)!;
if (seenSiteIds.has(site.siteId)) {
continue;
}
seenSiteIds.add(site.siteId);
item.sites.push(site);
const access = formatSiteResourceAccess({
mode: row.mode,
destination: row.destination,
destinationPort: row.destinationPort,
scheme: row.scheme,
ssl: row.ssl,
fullDomain: row.fullDomain,
alias: row.alias,
aliasAddress: row.aliasAddress
});
result.push({
launcherResourceKey: key,
resourceType: "site",
resourceId: row.siteResourceId,
siteResourceId: row.siteResourceId,
niceId: row.niceId,
name: row.name,
...access,
iconUrl: null,
enabled: row.enabled,
mode: row.mode,
labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
site:
row.siteId != null
? {
siteId: row.siteId,
name: row.siteName!,
type: row.siteType!,
online: row.siteOnline ?? undefined
}
: undefined
});
}
for (const item of byKey.values()) {
item.sites.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
item.site = pickPrimarySite(item.sites, siteIdFilter);
}
return Array.from(byKey.values());
return result;
}
function filterResourcesBySite(
@@ -1327,17 +1252,13 @@ function filterResourcesBySite(
return items.filter((item) => item.mode === "inference");
}
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
return items.filter(
(item) => item.sites.length === 0 && item.mode !== "inference"
);
return items.filter((item) => !item.site && item.mode !== "inference");
}
const siteId = Number.parseInt(groupKey, 10);
if (!Number.isFinite(siteId)) {
return items;
}
return items.filter((item) =>
item.sites.some((site) => site.siteId === siteId)
);
return items.filter((item) => item.site?.siteId === siteId);
}
function filterResourcesByLabel(
@@ -1578,7 +1499,6 @@ async function collectAccessibleSites(
.select({
siteId: sites.siteId,
name: sites.name,
niceId: sites.niceId,
type: sites.type,
online: sites.online,
itemCount: countDistinct(resources.resourceId)
@@ -1587,13 +1507,7 @@ async function collectAccessibleSites(
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(...publicConditions))
.groupBy(
sites.siteId,
sites.name,
sites.niceId,
sites.type,
sites.online
);
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of publicRows) {
const existing = siteCountMap.get(row.siteId);
@@ -1603,7 +1517,6 @@ async function collectAccessibleSites(
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
niceId: row.niceId,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
@@ -1627,7 +1540,6 @@ async function collectAccessibleSites(
.select({
siteId: sites.siteId,
name: sites.name,
niceId: sites.niceId,
type: sites.type,
online: sites.online,
itemCount: countDistinct(siteResources.siteResourceId)
@@ -1639,13 +1551,7 @@ async function collectAccessibleSites(
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(and(...siteConditions))
.groupBy(
sites.siteId,
sites.name,
sites.niceId,
sites.type,
sites.online
);
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of siteRows) {
const existing = siteCountMap.get(row.siteId);
@@ -1655,7 +1561,6 @@ async function collectAccessibleSites(
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
niceId: row.niceId,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
@@ -1770,7 +1675,6 @@ export async function listAccessibleLauncherSitesForUser(
.map((row) => ({
siteId: row.siteId,
name: row.name,
niceId: row.niceId,
type: row.type,
online: row.online
}))
+2 -3
View File
@@ -32,7 +32,6 @@ export type LauncherLabel = {
export type LauncherSiteInfo = {
siteId: number;
name: string;
niceId: string;
type: string;
online?: boolean;
};
@@ -52,7 +51,6 @@ export type LauncherResource = {
mode: string;
labels: LauncherLabel[];
site?: LauncherSiteInfo;
sites: LauncherSiteInfo[];
};
export type LauncherGroup = {
@@ -186,7 +184,8 @@ export function parseIdListParam(value: string | undefined): number[] {
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
{ type: "default" } | { type: "saved"; viewId: number };
| { type: "default" }
| { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Resource not found`
);
await sendCancel();
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (currentResourceAssociationCaches.length === 0) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!resource.networkId) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
await sendCancel();
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -164,7 +164,9 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (sitesToProcess.length === 0) {
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel();
return;
}
@@ -191,7 +193,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!site.exitNodeId) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
@@ -203,7 +205,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
@@ -227,7 +229,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!handshakeInitiated) {
logger.warn(
logger.error(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
);
await sendCancel();
-98
View File
@@ -1,98 +0,0 @@
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
@@ -1,241 +0,0 @@
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,5 +9,3 @@ export * from "./listOrgs";
export * from "./pickOrgDefaults";
export * from "./checkOrgUserAccess";
export * from "./resetOrgBandwidth";
export * from "./adminListOrgs";
export * from "./adminDeleteOrg";
+3 -17
View File
@@ -4,7 +4,7 @@ import { db, idp, users } 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 { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -88,15 +88,6 @@ const listUsersSchema = z.strictObject({
type: "boolean",
description:
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
}),
server_admin: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter by server admin status"
})
});
@@ -186,8 +177,7 @@ export async function adminListUsers(
sort_by,
order,
idp_id,
two_factor: twoFactorFilter,
server_admin: serverAdminFilter
two_factor: twoFactorFilter
} = parsedQuery.data;
if (typeof idp_id === "number") {
@@ -206,7 +196,7 @@ export async function adminListUsers(
}
}
const conditions: Array<SQL<unknown> | undefined> = [];
const conditions = [eq(users.serverAdmin, false)];
if (query) {
const q = "%" + query.toLowerCase() + "%";
@@ -243,10 +233,6 @@ export async function adminListUsers(
}
}
if (typeof serverAdminFilter === "boolean") {
conditions.push(eq(users.serverAdmin, serverAdminFilter));
}
const whereClause = and(...conditions);
const countQuery = db.$count(
-151
View File
@@ -1,151 +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,
type: users.type
})
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!existingUser) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (existingUser.type !== "internal") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin status can only be changed for internal users"
)
);
}
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 "./adminGetUser";
export * from "./adminGeneratePasswordResetCode";
export * from "./adminSetServerAdmin";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";
+29 -56
View File
@@ -95,34 +95,22 @@ const listUsersSchema = z.strictObject({
'Filter by identity provider id, or "internal" for internal users'
}),
role_id: z
.preprocess(
(val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
const raw = Array.isArray(val) ? val : [val];
const includeOwner = raw.some((v) => v === "owner");
const nums = raw
.map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v)
)
.filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)];
if (!unique.length && !includeOwner) {
return undefined;
}
return { roleIds: unique, includeOwner };
},
z
.object({
roleIds: z.array(z.number().int().positive()),
includeOwner: z.boolean()
})
.optional()
)
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
const raw = Array.isArray(val) ? val : [val];
const nums = raw
.map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v)
)
.filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)];
return unique.length ? unique : undefined;
}, z.array(z.number().int().positive()).optional())
.openapi({
description:
'Filter users who have any of these role ids in the organization, or "owner" for organization owners (repeat query param)'
"Filter users who have any of these role ids in the organization (repeat query param)"
})
});
@@ -205,8 +193,7 @@ export async function listUsers(
}
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
parsedQuery.data;
const roleIds = role_id?.roleIds ?? [];
const includeOwner = role_id?.includeOwner ?? false;
const roleIds = role_id ?? [];
const parsedParams = listUsersParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -280,35 +267,21 @@ export async function listUsers(
conditions.push(eq(users.idpId, idp_id));
}
if (roleIds.length > 0 || includeOwner) {
const roleFilterParts = [];
if (includeOwner) {
roleFilterParts.push(eq(userOrgs.isOwner, true));
}
if (roleIds.length > 0) {
roleFilterParts.push(
exists(
db
.select()
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, users.userId),
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.roleId, roleIds)
)
if (roleIds.length > 0) {
conditions.push(
exists(
db
.select()
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, users.userId),
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.roleId, roleIds)
)
)
);
}
if (roleFilterParts.length === 1) {
conditions.push(roleFilterParts[0]);
} else if (roleFilterParts.length > 1) {
conditions.push(or(...roleFilterParts));
}
)
)
);
}
const countQuery = db.$count(
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
subscriptionStatus = subRes.data.data;
} catch (error) {
// If subscription fetch fails, keep subscriptionStatus as null
// console.error("Failed to fetch subscription status:", error);
console.error("Failed to fetch subscription status:", error);
}
}
+6 -2
View File
@@ -1,5 +1,9 @@
import OrgRouteLoading from "@app/components/OrgRouteLoading";
import { Loader2 } from "lucide-react";
export default function OrgPageLoading() {
return <OrgRouteLoading />;
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -38,7 +38,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function GeneralPage() {
export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
const { env } = useEnvContext();
@@ -57,7 +57,7 @@ export default function GeneralPage() {
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const generalFormSchema = z.object({
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z
@@ -72,7 +72,7 @@ export default function GeneralPage() {
});
const form = useForm({
resolver: zodResolver(generalFormSchema),
resolver: zodResolver(accessControlsFormSchema),
defaultValues: {
username: user.username!,
autoProvisioned: user.autoProvisioned || false,
@@ -155,7 +155,7 @@ export default function GeneralPage() {
}
}
async function handleGeneralSubmit(e: React.FormEvent) {
async function handleAccessControlsSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
@@ -196,9 +196,11 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
<SettingsSectionTitle>
{t("accessControls")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userGeneralSettingsDescription")}
{t("accessControlsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -206,9 +208,11 @@ export default function GeneralPage() {
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={(e) => void handleGeneralSubmit(e)}
onSubmit={(e) =>
void handleAccessControlsSubmit(e)
}
className="space-y-4"
id="user-general-form"
id="access-controls-form"
>
{user.type !== UserType.Internal &&
user.idpType && (
@@ -277,9 +281,9 @@ export default function GeneralPage() {
type="submit"
loading={isSaving}
disabled={isSaving}
form="user-general-form"
form="access-controls-form"
>
{t("saveSettings")}
{t("accessControlsSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -9,16 +9,15 @@ import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export const metadata: Metadata = {
title: "User"
};
type UserLayoutProps = {
interface UserLayoutProps {
children: React.ReactNode;
params: Promise<{ userId: string; orgId: string }>;
};
}
export default async function UserLayoutProps(props: UserLayoutProps) {
const params = await props.params;
@@ -43,23 +42,15 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [
{
title: t("general"),
href: "/{orgId}/settings/access/users/{userId}/general"
title: t("accessControls"),
href: "/{orgId}/settings/access/users/{userId}/access-controls"
}
];
return (
<>
<SettingsSectionTitle
title={
user
? getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
: ""
}
title={`${user?.email}`}
description={t("userDescription2")}
/>
<OrgUserProvider orgUser={user}>
@@ -9,5 +9,5 @@ export default async function UserPage(props: {
params: Promise<{ orgId: string; userId: string }>;
}) {
const { orgId, userId } = await props.params;
redirect(`/${orgId}/settings/access/users/${userId}/general`);
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
}
@@ -1,20 +1,24 @@
"use client";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { StrategySelect } from "@app/components/StrategySelect";
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { useActionState, useRef, useState, startTransition } from "react";
import {
useActionState,
useRef,
useState,
startTransition
} from "react";
import {
Form,
FormControl,
@@ -38,6 +42,7 @@ import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
@@ -50,15 +55,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import CopyToClipboard from "@app/components/CopyToClipboard";
type UserType = "internal" | "oidc";
@@ -99,7 +96,6 @@ export default function Page() {
"internal"
);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
@@ -250,9 +246,9 @@ export default function Page() {
build === "saas" || env.app.identityProviderMode === "org";
const res = await api
.get<AxiosResponse<ListIdpsResponse>>(
useOrgIdps ? `/org/${orgId}/idp` : "/idp"
)
.get<
AxiosResponse<ListIdpsResponse>
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
.catch((e) => {
console.error(e);
toast({
@@ -354,13 +350,13 @@ export default function Page() {
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
setExpiresInDays(parseInt(values.validForHours) / 24);
setIsInviteDialogOpen(true);
toast({
variant: "default",
title: t("userInvited"),
description: t("userInvitedDescription")
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
}
@@ -464,7 +460,6 @@ export default function Page() {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setIsInviteDialogOpen(false);
setExpiresInDays(1);
} else {
googleAzureForm.reset();
@@ -491,7 +486,7 @@ export default function Page() {
<div>
<SettingsContainer>
{userOptions.length > 1 ? (
{!inviteLink && userOptions.length > 1 ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -513,132 +508,177 @@ export default function Page() {
) : null}
{selectedOption === "internal" && dataLoaded && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
submitInternalAction();
});
}}
className="space-y-4"
id="create-user-form"
>
<FormField
control={internalForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<>
{!inviteLink ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
submitInternalAction();
});
}}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(
e as boolean
)
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"inviteValid"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<OrgRolesTagField
form={internalForm}
name="roles"
orgId={orgId as string}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label>
</div>
)}
<FormField
control={internalForm.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("inviteValid")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={
sendEmail
}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"inviteEmailSent"
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
</label>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userInvited")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{sendEmail
? t(
"inviteEmailSentDescription"
)
: t("inviteSentDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
<CopyToClipboard
text={inviteLink}
/>
<OrgRolesTagField
form={internalForm}
name="roles"
orgId={orgId as string}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</div>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
{selectedOption &&
@@ -858,56 +898,24 @@ export default function Page() {
<div className="flex justify-end space-x-2 mt-8">
{selectedOption && dataLoaded && (
<Button
type="submit"
form="create-user-form"
type={inviteLink ? "button" : "submit"}
form={inviteLink ? undefined : "create-user-form"}
loading={loading}
disabled={loading}
onClick={
inviteLink
? () =>
router.push(
`/${orgId}/settings/access/users`
)
: undefined
}
>
{t("accessUserCreate")}
{inviteLink ? t("done") : t("accessUserCreate")}
</Button>
)}
</div>
</div>
<Credenza
open={isInviteDialogOpen}
onOpenChange={(open) => {
setIsInviteDialogOpen(open);
if (!open) {
setInviteLink(null);
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("userInvited")}</CredenzaTitle>
<CredenzaDescription>
{sendEmail
? t("inviteEmailSentDescription")
: t("inviteSentDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
onClick={() =>
router.push(`/${orgId}/settings/access/users`)
}
>
{t("done")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
+12 -11
View File
@@ -78,13 +78,12 @@ export default async function UsersPage(props: UsersPageProps) {
rolesRes && rolesRes.status === 200
? (rolesRes.data.data.roles ?? [])
: [];
const roleFilterOptions = [
{ value: "owner", label: t("accessRoleOwner") },
...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({
const roleFilterOptions = orgRoles.map(
(r: ListRolesResponse["roles"][number]) => ({
value: String(r.roleId),
label: r.name
}))
];
})
);
const invitationsRes = await internal
.get(
@@ -127,12 +126,14 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
roleLabels: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
roleLabels: user.isOwner
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
isOwner: user.isOwner || false
};
});
+248 -161
View File
@@ -43,18 +43,10 @@ import {
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import moment from "moment";
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() {
const { env } = useEnvContext();
@@ -66,7 +58,6 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
@@ -84,6 +75,22 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({
resolver: zodResolver(createFormSchema),
defaultValues: {
@@ -91,9 +98,12 @@ export default function Page() {
}
});
function goToApiKeysList() {
router.push(`/${orgId}/settings/api-keys`);
}
const copiedForm = useForm({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: true
}
});
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
@@ -103,10 +113,9 @@ export default function Page() {
};
const res = await api
.put<AxiosResponse<CreateOrgApiKeyResponse>>(
`/org/${orgId}/api-key/`,
payload
)
.put<
AxiosResponse<CreateOrgApiKeyResponse>
>(`/org/${orgId}/api-key/`, payload)
.catch((e) => {
toast({
variant: "destructive",
@@ -116,10 +125,16 @@ export default function Page() {
});
if (res && res.status === 201) {
const created = res.data.data;
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api
.post(`/org/${orgId}/api-key/${created.apiKeyId}/actions`, {
.post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
@@ -134,14 +149,27 @@ export default function Page() {
});
if (actionsRes) {
setApiKey(created);
setIsApiKeyDialogOpen(true);
setApiKey(data);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/${orgId}/settings/api-keys`);
}
const formatLabel = (str: string) => {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());
};
useEffect(() => {
const load = async () => {
setLoadingPage(false);
@@ -157,7 +185,12 @@ export default function Page() {
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button variant="outline" onClick={goToApiKeysList}>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -165,152 +198,206 @@ export default function Page() {
{!loadingPage && (
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("apiKeysGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div>
)}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && (
<div className="space-y-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(apiKey.createdAt).format(
"lll"
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
@@ -954,6 +954,7 @@ export const ProxyResourceTargetsForm = forwardRef<
colSpan={columns.length}
message={emptyMessage ?? t("targetNoOne")}
action={addTargetButton}
compact
/>
)}
</TableBody>
+239 -158
View File
@@ -46,15 +46,6 @@ import moment from "moment";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() {
const { env } = useEnvContext();
@@ -65,7 +56,6 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
@@ -83,6 +73,22 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({
resolver: zodResolver(createFormSchema),
defaultValues: {
@@ -90,9 +96,12 @@ export default function Page() {
}
});
function goToApiKeysList() {
router.push(`/admin/api-keys`);
}
const copiedForm = useForm({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: true
}
});
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
@@ -112,10 +121,16 @@ export default function Page() {
});
if (res && res.status === 201) {
const created = res.data.data;
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api
.post(`/api-key/${created.apiKeyId}/actions`, {
.post(`/api-key/${data.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
@@ -130,14 +145,21 @@ export default function Page() {
});
if (actionsRes) {
setApiKey(created);
setIsApiKeyDialogOpen(true);
setApiKey(data);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/admin/api-keys`);
}
useEffect(() => {
const load = async () => {
setLoadingPage(false);
@@ -153,7 +175,12 @@ export default function Page() {
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button variant="outline" onClick={goToApiKeysList}>
<Button
variant="outline"
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -161,153 +188,207 @@ export default function Page() {
{!loadingPage && (
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("apiKeysGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
root={true}
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
root={true}
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div>
)}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && (
<div className="space-y-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(apiKey.createdAt).format(
"lll"
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
-59
View File
@@ -1,59 +0,0 @@
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
}}
/>
</>
);
}
+1 -1
View File
@@ -65,7 +65,7 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #0c0c0d;
--sidebar: #0C0C0D;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+1 -7
View File
@@ -6,7 +6,6 @@ import {
Bot,
Boxes,
Building2,
Building2Icon,
Cable,
ChartLine,
Coins,
@@ -377,11 +376,6 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/users",
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarOrgs",
href: "/admin/organizations",
icon: <Building2Icon className="size-4 flex-none" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
@@ -398,7 +392,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
}
]
: []),
...(build === "enterprise"
...(build == "enterprise"
? [
{
title: "sidebarLicense",
+2 -227
View File
@@ -19,8 +19,7 @@ import {
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
ShieldUserIcon
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -44,13 +43,6 @@ import {
CredenzaClose
} from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { useUserContext } from "@app/hooks/useUserContext";
export type GlobalUserRow = {
id: string;
@@ -98,11 +90,6 @@ export default function UsersTable({
const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
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 {
@@ -122,11 +109,6 @@ export default function UsersTable({
.optional()
.catch(undefined);
const serverAdminFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
@@ -202,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) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
@@ -301,32 +235,7 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" />
</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-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
}
},
{
accessorKey: "email",
@@ -432,37 +341,6 @@ export default function UsersTable({
);
}
},
{
accessorKey: "serverAdmin",
friendlyName: t("serverAdmin"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("yes") },
{ value: "false", label: t("no") }
]}
selectedValue={serverAdminFilterSchema.parse(
searchParams.get("server_admin") ?? undefined
)}
onValueChange={(value) =>
handleFilterChange("server_admin", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("serverAdmin")}
className="p-3"
/>
),
cell: ({ row }) => (
<span>
{row.original.serverAdmin ? (
<span>{t("yes")}</span>
) : (
<span>{t("no")}</span>
)}
</span>
)
},
{
id: "actions",
enableHiding: false,
@@ -491,34 +369,11 @@ export default function UsersTable({
{t("generatePasswordResetCode")}
</DropdownMenuItem>
)}
{r.type === "internal" && !r.serverAdmin && (
<DropdownMenuItem
onClick={() => {
setPromoting(r);
setIsPromoteModalOpen(true);
}}
>
{t("promoteServerAdmin")}
</DropdownMenuItem>
)}
{r.type === "internal" &&
r.serverAdmin &&
r.id !== user.user.userId && (
<DropdownMenuItem
onClick={() => {
setDemoting(r);
setIsDemoteModalOpen(true);
}}
>
{t("demoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
className="text-red-400"
>
{t("delete")}
</DropdownMenuItem>
@@ -580,86 +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", {
selectedUser: getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})
})}
</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
columns={columns}
rows={users}
+1 -1
View File
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
<div className="inline-flex items-start md:items-center gap-2">
<span>
<Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/general`}
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`}
className="text-primary hover:underline cursor-pointer"
>
{getUserDisplayName({
+103 -48
View File
@@ -1,12 +1,15 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
ShieldCheck,
Check,
Ban,
User,
Settings,
ArrowRight
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -18,50 +21,102 @@ export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
const t = useTranslations();
return (
<SettingsSection>
<SettingsSectionBody>
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<ShieldCheck className="h-8 w-8 text-primary" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
{t("approvalsEmptyStateTitle")}
</h2>
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStateDescription")}
</p>
<div className="flex flex-col gap-6">
<Card>
<CardContent className="p-6 md:p-12">
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto">
<div className="space-y-2">
<h3 className="text-xl md:text-2xl font-semibold">
{t("approvalsEmptyStateTitle")}
</h3>
<p className="text-muted-foreground text-sm md:text-lg">
{t("approvalsEmptyStateDescription")}
</p>
</div>
<div className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("approvalsEmptyStateHowToTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep1Description")}
</span>
</li>
<li className="flex items-start gap-2">
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep2Description")}
</span>
</li>
</ul>
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4">
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border">
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep1Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep1Description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<User className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep2Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep2Description"
)}
</p>
</div>
</div>
</div>
{/* Abstract UI Preview - Hidden on mobile */}
<div className="hidden md:block bg-muted/50 rounded-lg p-6 border">
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-green-500/20 rounded flex items-center justify-center">
<Check className="w-3 h-3 text-green-600" />
</div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<Link href={`/${orgId}/settings/access/roles`} className="w-full md:w-auto">
<Button className="gap-2 mt-2 w-full md:w-auto">
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</div>
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStatePreviewDescription")}
</p>
</div>
</SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<Button asChild>
<Link href={`/${orgId}/settings/access/roles`}>
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</SettingsSectionFooter>
</SettingsSection>
</CardContent>
</Card>
</div>
);
}
-56
View File
@@ -1,56 +0,0 @@
import { cn } from "@app/lib/cn";
type LoadingDotsProps = {
className?: string;
size?: "sm" | "md";
};
const sizeClasses = {
sm: {
gap: "gap-1.5",
dot: "h-1.5 w-1.5"
},
md: {
gap: "gap-2.5",
dot: "h-2.5 w-2.5"
}
} as const;
export default function LoadingDots({
className,
size = "md"
}: LoadingDotsProps) {
const classes = sizeClasses[size];
return (
<span
className={cn(
"flex items-center text-muted-foreground",
classes.gap,
className
)}
>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "0ms" }}
/>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "200ms" }}
/>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "400ms" }}
/>
</span>
);
}
+27 -21
View File
@@ -1,18 +1,19 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Check, Languages } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Check, Globe, Languages } from "lucide-react";
import clsx from "clsx";
import { useTransition } from "react";
import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
type Props = {
defaultValue: string;
@@ -42,31 +43,36 @@ export default function LocaleSwitcherSelect({
const selected = items.find((item) => item.value === defaultValue);
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={cn(
"[&_svg:not([class*='text-'])]:text-muted-foreground",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="mr-2 h-4 w-4" />
<span>{selected?.label ?? label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[8rem]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="text-muted-foreground h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
<span>{item.label}</span>
{item.value === defaultValue && (
<Check className="ml-auto h-4 w-4" />
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
}
-45
View File
@@ -1,45 +0,0 @@
"use client";
import Link from "next/link";
import BrandingLogo from "@app/components/BrandingLogo";
import LoadingDots from "@app/components/LoadingDots";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export default function OrgRouteLoading() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
: 98;
const logoHeight = isUnlocked()
? env.branding.logo?.navbar?.height || 32
: 32;
return (
<div className="relative h-screen-safe overflow-hidden">
<div className="absolute top-0 left-0 right-0 z-50">
<div className="px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="flex h-16 items-center">
<Link
href="/"
className="flex shrink-0 items-center"
>
<BrandingLogo
width={logoWidth}
height={logoHeight}
/>
</Link>
</div>
</div>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<LoadingDots size="md" />
</div>
</div>
);
}
+9 -1
View File
@@ -27,7 +27,15 @@ export function OrgSelector({
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const picker = (
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
<OrgPicker
orgId={orgId}
orgs={orgs}
contentClassName={
isCollapsed
? "w-[320px]"
: "w-[var(--radix-popover-trigger-width)]"
}
>
<div
role="combobox"
className={cn(
-290
View File
@@ -1,290 +0,0 @@
"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";
import CopyToClipboard from "./CopyToClipboard";
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: "orgId",
friendlyName: t("orgId"),
header: () => <span className="p-3">{t("orgId")}</span>,
cell: ({ row }) => (
<CopyToClipboard text={row.original.orgId} isLink={false} />
)
},
{
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: "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>{row.original.userCount}</span>
},
{
accessorKey: "siteCount",
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => <span>{row.original.siteCount}</span>
},
{
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => <span>{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"
>
{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
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
+29 -39
View File
@@ -9,16 +9,13 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { Check, Laptop, Moon, Sun, Trash2 } from "lucide-react";
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import Link from "next/link";
@@ -152,49 +149,42 @@ export default function ProfileIcon() {
>
<span>{t("changePassword")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
<Smartphone className="mr-2 h-4 w-4" />
<span>{t("viewDevices") || "View Devices"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
{userTheme === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{userTheme === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{userTheme === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">{t(userTheme)}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[8rem]">
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() =>
handleThemeChange(themeOption)
}
className="flex items-center gap-2"
>
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<Check className="ml-auto h-4 w-4" />
)}
</DropdownMenuItem>
)
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() => handleThemeChange(themeOption)}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
<LocaleSwitcher />
<DropdownMenuSeparator />
{user?.type === UserType.Internal && !user?.serverAdmin && (
+3 -1
View File
@@ -21,12 +21,14 @@ import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import { build } from "@server/build";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
+37 -55
View File
@@ -52,11 +52,9 @@ export default function RegenerateInvitationForm({
}: RegenerateInvitationFormProps) {
const [loading, setLoading] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [expiresInDays, setExpiresInDays] = useState(3);
const { env } = useEnvContext();
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [sendEmail, setSendEmail] = useState(true);
const [validHours, setValidHours] = useState(72);
const api = createApiClient({ env });
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const t = useTranslations();
@@ -73,11 +71,10 @@ export default function RegenerateInvitationForm({
useEffect(() => {
if (open) {
setSendEmail(env.email.emailEnabled);
setSendEmail(true);
setValidHours(72);
setExpiresInDays(3);
}
}, [open, env.email.emailEnabled]);
}, [open]);
async function handleRegenerate() {
if (!invitation) return;
@@ -99,16 +96,15 @@ export default function RegenerateInvitationForm({
email: invitation.email,
roleIds: invitation.roleIds,
validHours,
sendEmail: env.email.emailEnabled && sendEmail,
sendEmail,
regenerate: true
});
if (res.status === 200) {
const link = res.data.data.inviteLink;
setInviteLink(link);
setExpiresInDays(validHours / 24);
if (sendEmail && env.email.emailEnabled) {
if (sendEmail) {
toast({
variant: "default",
title: t("inviteRegenerated"),
@@ -131,7 +127,9 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: new Date(res.data.data.expiresAt).toISOString(),
expiresAt: new Date(
res.data.data.expiresAt
).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
});
@@ -176,47 +174,33 @@ export default function RegenerateInvitationForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{inviteLink
? t("inviteRegenerated")
: t("inviteRegenerate")}
</CredenzaTitle>
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
<CredenzaDescription>
{inviteLink
? sendEmail && env.email.emailEnabled
? t("inviteEmailSentDescription")
: t("inviteSentDescription")
: t("inviteRegenerateDescription")}
{t("inviteRegenerateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!inviteLink ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("email")}</Label>
<p className="text-sm">{invitation?.email}</p>
<div>
<p>
{t("inviteQuestionRegenerate", {
email: invitation?.email || ""
})}
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
{t("inviteSentEmail")}
</label>
</div>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label>
</div>
)}
<div className="space-y-2">
<Label>{t("inviteValid")}</Label>
<div className="mt-4 space-y-2">
<Label>{t("inviteValidityPeriod")}</Label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
@@ -225,7 +209,9 @@ export default function RegenerateInvitationForm({
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectDuration")}
placeholder={t(
"inviteValidityPeriodSelect"
)}
/>
</SelectTrigger>
<SelectContent>
@@ -242,13 +228,9 @@ export default function RegenerateInvitationForm({
</div>
</div>
) : (
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
<div className="space-y-4 max-w-md">
<p>{t("inviteRegenerateMessage")}</p>
<CopyTextBox text={inviteLink} wrapText={false} />
</div>
)}
</CredenzaBody>
@@ -267,7 +249,7 @@ export default function RegenerateInvitationForm({
</>
) : (
<CredenzaClose asChild>
<Button>{t("done")}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
)}
</CredenzaFooter>
+1
View File
@@ -52,6 +52,7 @@ import {
} from "./ui/controlled-data-table";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { durationToMs } from "@app/lib/durationToMs";
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
+4 -9
View File
@@ -8,32 +8,27 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
const MAX_ROLE_BADGES = 3;
export default function UserRoleBadges({
roleLabels,
isOwner
roleLabels
}: {
roleLabels: string[];
isOwner?: boolean;
}) {
const t = useTranslations();
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{isOwner && (
<Badge variant="secondary">{t("accessRoleOwner")}</Badge>
)}
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && <OverflowRolesPopover labels={overflow} />}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
+4 -49
View File
@@ -22,7 +22,6 @@ import {
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
Crown,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -34,20 +33,12 @@ import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import UserRoleBadges from "./UserRoleBadges";
const OWNER_FILTER_VALUE = "owner";
export type UserRow = {
id: string;
email: string | null;
@@ -104,13 +95,7 @@ export default function UsersTable({
const roleIdsFromSearchParams = useMemo(() => {
const sp = new URLSearchParams(searchParams);
return [
...new Set(
sp
.getAll("role_id")
.filter(
(id) => /^\d+$/.test(id) || id === OWNER_FILTER_VALUE
)
)
...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id)))
];
}, [searchParams.toString()]);
@@ -141,7 +126,7 @@ export default function UsersTable({
sp.delete("role_id");
sp.delete("page");
for (const id of values) {
if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) {
if (/^\d+$/.test(id)) {
sp.append("role_id", id);
}
}
@@ -186,32 +171,7 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.displayUsername}{" "}
{row.original.id === user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.isOwner && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Crown className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("accessRoleOwner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
}
},
{
accessorKey: "idpName",
@@ -262,12 +222,7 @@ export default function UsersTable({
);
},
cell: ({ row }) => {
return (
<UserRoleBadges
roleLabels={row.original.roleLabels}
isOwner={row.original.isOwner}
/>
);
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
}
},
{
@@ -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">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup
heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm"
/>
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
{!isActionMode &&
navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}>
@@ -9,7 +9,6 @@ import type {
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
@@ -82,8 +81,8 @@ export function LauncherFlatResourceList({
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16">
<LoadingDots size="sm" />
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
@@ -8,7 +8,6 @@ import type {
LauncherViewConfig
} from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
@@ -110,8 +109,8 @@ export function LauncherGroupList({
if (groups.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16">
<LoadingDots size="sm" />
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
@@ -22,7 +22,6 @@ import {
LAUNCHER_UNLABELED_GROUP_KEY
} from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
@@ -170,8 +169,8 @@ export function LauncherGroupSection({
<CollapsibleContent className="w-full">
{showInitialLoader ? (
<div className="flex items-center justify-center py-10">
<LoadingDots size="sm" />
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : resources.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
@@ -1,8 +1,6 @@
"use client";
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
@@ -30,23 +28,7 @@ import {
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import type { AiConfigBlock } from "@app/lib/aiClientConfig";
import { cn } from "@app/lib/cn";
import {
derivePublicAuthState,
formatPublicResourceType
@@ -54,10 +36,7 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
import type {
LauncherResource,
LauncherSiteInfo
} from "@server/routers/launcher/types";
import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
@@ -65,18 +44,15 @@ import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
ChevronsUpDown,
Clock,
ExternalLink,
Loader2,
MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
type LauncherResourcePanelProps = {
open: boolean;
@@ -84,7 +60,6 @@ type LauncherResourcePanelProps = {
resource: LauncherResource | null;
orgId: string;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
};
type LauncherResourceDetailResult =
@@ -178,249 +153,6 @@ function HealthStatusDisplay({
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"];
type SitesSortKey = "name" | "status";
type SitesSortOrder = "asc" | "desc";
function siteStatusSortValue(site: LauncherSiteInfo): number {
if (site.type !== "newt" && site.type !== "wireguard") {
return -1;
}
if (typeof site.online !== "boolean") {
return -1;
}
return site.online ? 1 : 0;
}
function SiteStatusCell({ site }: { site: LauncherSiteInfo }) {
const t = useTranslations();
if (site.type !== "newt" && site.type !== "wireguard") {
return <span>-</span>;
}
if (typeof site.online !== "boolean") {
return <span>-</span>;
}
return (
<span className="flex items-center gap-2">
<span
className={cn(
"size-2 shrink-0 rounded-full",
site.online ? "bg-green-500" : "bg-neutral-500"
)}
/>
<span>{site.online ? t("online") : t("offline")}</span>
</span>
);
}
function LauncherResourceSitesSection({
orgId,
sites,
isAdmin,
onFilterBySite
}: {
orgId: string;
sites: LauncherSiteInfo[];
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const [sortKey, setSortKey] = useState<SitesSortKey>("name");
const [sortOrder, setSortOrder] = useState<SitesSortOrder>("asc");
if (sites.length === 0) {
return null;
}
function toggleSort(key: SitesSortKey) {
if (sortKey === key) {
setSortOrder((order) => (order === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortOrder("asc");
}
const sortedSites = [...sites].sort((a, b) => {
const cmp =
sortKey === "name"
? a.name.localeCompare(b.name, undefined, {
sensitivity: "base"
})
: siteStatusSortValue(a) - siteStatusSortValue(b);
return sortOrder === "desc" ? -cmp : cmp;
});
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("sites")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherSitesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="min-w-0 overflow-x-auto overflow-y-hidden">
<Table sticky>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead className="whitespace-nowrap">
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("status")}
>
{t("status")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead
className={cn(
"whitespace-nowrap text-right",
"sticky right-0 z-10 w-auto min-w-fit bg-card",
"[mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"
)}
>
<span className="sr-only">
{t("actions")}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSites.map((site) => (
<TableRow key={site.siteId}>
<TableCell className="whitespace-nowrap">
{site.name}
</TableCell>
<TableCell className="whitespace-nowrap">
<SiteStatusCell site={site} />
</TableCell>
<TableCell
className={cn(
"whitespace-nowrap text-right",
"sticky right-0 z-10 w-auto min-w-fit bg-card",
"[mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"
)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isAdmin ? (
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/sites/${site.niceId}`}
>
{t(
"resourceLauncherViewSiteAsAdmin"
)}
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() =>
onFilterBySite(site)
}
>
{t(
"resourceLauncherFilterBySite"
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingsSectionBody>
</SettingsSection>
);
}
function LauncherPrivateSshCommandSection({
alias,
niceId,
pamMode
}: {
alias?: string | null;
niceId: string;
pamMode?: string | null;
}) {
const t = useTranslations();
const trimmedAlias = alias?.trim() || null;
const hasDistinctAlias = Boolean(trimmedAlias && trimmedAlias !== niceId);
const useUserPrefix = pamMode !== "push";
function formatSshTarget(target: string) {
return useUserPrefix ? `user@${target}` : target;
}
const blocks: AiConfigBlock[] = hasDistinctAlias
? [
{
id: "alias",
label: t("editInternalResourceDialogAlias"),
displayText: `pangolin ssh ${formatSshTarget(trimmedAlias!)}`
},
{
id: "niceId",
label: t("identifier"),
displayText: `pangolin ssh ${formatSshTarget(niceId)}`
}
]
: [
{
id: "niceId",
label: t("identifier"),
displayText: `pangolin ssh ${formatSshTarget(niceId)}`
}
];
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherSshCommand")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherSshCommandDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{hasDistinctAlias ? (
<AiConfigBlocks blocks={blocks} relation="options" />
) : (
<CopyTextBox text={blocks[0].displayText} />
)}
</SettingsSectionBody>
</SettingsSection>
);
}
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
const t = useTranslations();
@@ -503,16 +235,12 @@ function PublicResourceDetails({
orgId,
launcherResource,
resource,
authInfo,
isAdmin,
onFilterBySite
authInfo
}: {
orgId: string;
launcherResource: LauncherResource;
resource: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const mode = resource.mode || "";
@@ -600,12 +328,6 @@ function PublicResourceDetails({
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{showAuthMethods ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : null}
@@ -641,19 +363,14 @@ function PublicResourceDetails({
function PrivateResourceDetails({
orgId,
launcherResource,
resource,
isAdmin,
onFilterBySite
resource
}: {
orgId: string;
launcherResource: LauncherResource;
resource: GetSiteResourceResponse;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const isInference = resource.mode === "inference";
const isSsh = resource.mode === "ssh";
return (
<div className="space-y-4">
@@ -700,19 +417,6 @@ function PrivateResourceDetails({
/>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{isSsh ? (
<LauncherPrivateSshCommandSection
alias={resource.alias}
niceId={resource.niceId}
pamMode={resource.pamMode}
/>
) : null}
{isInference ? (
<>
<LauncherInferenceModelsSection
@@ -736,15 +440,11 @@ function PrivateResourceDetails({
function LauncherResourcePanelBody({
orgId,
resource,
open,
isAdmin,
onFilterBySite
open
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const { data, isPending, isError } = useQuery({
@@ -777,8 +477,6 @@ function LauncherResourcePanelBody({
launcherResource={resource}
resource={detail.data}
authInfo={detail.authInfo}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
);
}
@@ -788,8 +486,6 @@ function LauncherResourcePanelBody({
orgId={orgId}
launcherResource={resource}
resource={detail.data}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
);
}
@@ -799,8 +495,7 @@ export function LauncherResourcePanel({
onOpenChange,
resource,
orgId,
isAdmin,
onFilterBySite
isAdmin
}: LauncherResourcePanelProps) {
const t = useTranslations();
@@ -816,8 +511,6 @@ export function LauncherResourcePanel({
orgId={orgId}
resource={resource}
open={open}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
) : null}
</SidePanelBody>
@@ -44,7 +44,6 @@ import {
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherSiteInfo,
type LauncherViewConfig,
type LauncherViewRecord,
type ListLauncherResourcesResponse
@@ -585,14 +584,6 @@ export default function ResourceLauncher({
}
}, []);
const handleFilterBySite = useCallback(
(site: LauncherSiteInfo) => {
applyConfigPatch({ siteIds: [site.siteId] });
handlePanelOpenChange(false);
},
[applyConfigPatch, handlePanelOpenChange]
);
const hasHandledAutoOpen = useRef(false);
useEffect(() => {
@@ -804,7 +795,6 @@ export default function ResourceLauncher({
resource={selectedResource}
orgId={orgId}
isAdmin={isAdmin}
onFilterBySite={handleFilterBySite}
/>
{activeSavedView ? (
@@ -961,6 +961,7 @@ export function PolicyAccessRulesTable({
colSpan={columns.length}
message={t("rulesNoOne")}
action={emptyStateAction}
compact
/>
)}
</TableBody>
+3 -3
View File
@@ -81,15 +81,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex items-center gap-1.5">
<span
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }}
/>
<span
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }}
/>
</span>
+23 -17
View File
@@ -5,46 +5,52 @@ import { useTranslations } from "next-intl";
import { type ReactNode } from "react";
const PLACEHOLDER_ROW_COUNT = 5;
const COMPACT_PLACEHOLDER_ROW_COUNT = 2;
type DataTableEmptyStateProps = {
colSpan: number;
action?: ReactNode;
message?: string;
compact?: boolean;
};
export function DataTableEmptyState({
colSpan,
action,
message
message,
compact = false
}: DataTableEmptyStateProps) {
const t = useTranslations();
const placeholderRows = compact
? COMPACT_PLACEHOLDER_ROW_COUNT
: PLACEHOLDER_ROW_COUNT;
const minHeightClass = compact ? "min-h-[4.5rem]" : "min-h-[11rem]";
return (
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={colSpan} className="p-0">
<div className="relative w-full overflow-hidden min-h-[12.5rem]">
<div
className={`relative w-full overflow-hidden ${minHeightClass}`}
>
<div
className="pointer-events-none absolute inset-0 flex flex-col justify-start opacity-50"
className="absolute inset-0 flex flex-col justify-start"
aria-hidden
>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
(_, i) => (
<div
key={i}
className="flex h-10 shrink-0 items-center border-b border-border/60 px-4 last:border-b-0"
>
<div className="h-3.5 w-full rounded bg-muted/60" />
</div>
)
)}
{Array.from({ length: placeholderRows }).map((_, i) => (
<div key={i} className="h-10 shrink-0" />
))}
</div>
<div className="relative flex w-full flex-col items-center justify-center px-4 min-h-[12.5rem] gap-4 py-8">
<div
className={`relative flex w-full flex-col items-center justify-center px-4 ${
compact
? "min-h-[4.5rem] gap-3 py-4"
: "min-h-[11rem] gap-4 py-8"
}`}
>
<p className="text-sm text-muted-foreground">
{message ?? t("noResults")}
</p>
{action ? (
<div className="bg-background">{action}</div>
) : null}
{action}
</div>
</div>
</TableCell>
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"paths": {
"@server/*": [