mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-14 14:54:49 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b7f59b90c | |||
| 17375348b0 | |||
| e7fdbf9e85 | |||
| cddb5ecc3d |
+14
-42
@@ -80,7 +80,7 @@
|
|||||||
"siteManageSites": "Manage Sites",
|
"siteManageSites": "Manage Sites",
|
||||||
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
||||||
"sitesBannerTitle": "Connect Any Network",
|
"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",
|
"sitesBannerButtonText": "Install Site Connector",
|
||||||
"approvalsBannerTitle": "Approve or Deny Device Access",
|
"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.",
|
"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",
|
"never": "Never",
|
||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"proxyResourceTitle": "Manage Public Resources",
|
"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",
|
"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",
|
"clientResourceTitle": "Manage Private Resources",
|
||||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||||
@@ -465,8 +465,6 @@
|
|||||||
"apiKeysDelete": "Delete API Key",
|
"apiKeysDelete": "Delete API Key",
|
||||||
"apiKeysManage": "Manage API Keys",
|
"apiKeysManage": "Manage API Keys",
|
||||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
||||||
"orgsManage": "Manage Organizations",
|
|
||||||
"orgsDescription": "View and manage all organizations on this instance",
|
|
||||||
"provisioningKeysTitle": "Provisioning Key",
|
"provisioningKeysTitle": "Provisioning Key",
|
||||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||||
@@ -522,12 +520,12 @@
|
|||||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||||
"pendingSitesBannerButtonText": "Learn More",
|
"pendingSitesBannerButtonText": "Learn More",
|
||||||
"apiKeysSettings": "{apiKeyName} Settings",
|
"apiKeysSettings": "{apiKeyName} Settings",
|
||||||
"userTitle": "Manage Users",
|
"userTitle": "Manage All Users",
|
||||||
"userDescription": "View and manage all users in this instance",
|
"userDescription": "View and manage all users in the system",
|
||||||
"userAbount": "About User Management",
|
"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.",
|
"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",
|
"userServer": "Server Users",
|
||||||
"userSearch": "Search users...",
|
"userSearch": "Search server users...",
|
||||||
"userErrorDelete": "Error deleting user",
|
"userErrorDelete": "Error deleting user",
|
||||||
"userDeleteConfirm": "Confirm Delete User",
|
"userDeleteConfirm": "Confirm Delete User",
|
||||||
"userDeleteServer": "Delete User from Server",
|
"userDeleteServer": "Delete User from Server",
|
||||||
@@ -596,7 +594,7 @@
|
|||||||
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
|
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
|
||||||
"invite": "Invitations",
|
"invite": "Invitations",
|
||||||
"inviteRegenerate": "Regenerate Invitation",
|
"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",
|
"inviteRemove": "Remove Invitation",
|
||||||
"inviteRemoveError": "Failed to remove invitation",
|
"inviteRemoveError": "Failed to remove invitation",
|
||||||
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
|
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
|
||||||
@@ -676,11 +674,11 @@
|
|||||||
"accessUserCreateDescription": "Follow the steps below to create a new user",
|
"accessUserCreateDescription": "Follow the steps below to create a new user",
|
||||||
"userSeeAll": "See All Users",
|
"userSeeAll": "See All Users",
|
||||||
"userTypeTitle": "User Type",
|
"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",
|
"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",
|
"inviteEmailSent": "Send invite email to user",
|
||||||
"inviteValid": "Invite Valid For",
|
"inviteValid": "Invite Valid For (days)",
|
||||||
"selectDuration": "Select duration",
|
"selectDuration": "Select duration",
|
||||||
"selectResource": "Select Resource",
|
"selectResource": "Select Resource",
|
||||||
"filterByResource": "Filter By Resource",
|
"filterByResource": "Filter By Resource",
|
||||||
@@ -718,7 +716,6 @@
|
|||||||
"nameOptional": "Name (Optional)",
|
"nameOptional": "Name (Optional)",
|
||||||
"accessControls": "Access Controls",
|
"accessControls": "Access Controls",
|
||||||
"userDescription2": "Manage the settings on this user",
|
"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",
|
"accessRoleErrorAdd": "Failed to add user to role",
|
||||||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||||
"userSaved": "User saved",
|
"userSaved": "User saved",
|
||||||
@@ -1217,7 +1214,7 @@
|
|||||||
"orgPoliciesEdit": "Edit Organization Policy",
|
"orgPoliciesEdit": "Edit Organization Policy",
|
||||||
"org": "Organization",
|
"org": "Organization",
|
||||||
"orgSelect": "Select organization",
|
"orgSelect": "Select organization",
|
||||||
"orgSearch": "Search organizations...",
|
"orgSearch": "Search org",
|
||||||
"orgNotFound": "No org found.",
|
"orgNotFound": "No org found.",
|
||||||
"roleMappingPathOptional": "Role Mapping Path (Optional)",
|
"roleMappingPathOptional": "Role Mapping Path (Optional)",
|
||||||
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
|
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
|
||||||
@@ -1356,7 +1353,7 @@
|
|||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "No labels found.",
|
"labelsNotFound": "No labels found.",
|
||||||
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
||||||
"labelSearch": "Search labels...",
|
"labelSearch": "Search labels",
|
||||||
"labelSearchOrCreate": "Search or create a label",
|
"labelSearchOrCreate": "Search or create a label",
|
||||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
@@ -1427,24 +1424,6 @@
|
|||||||
"logoutError": "Error logging out",
|
"logoutError": "Error logging out",
|
||||||
"signingAs": "Signed in as",
|
"signingAs": "Signed in as",
|
||||||
"serverAdmin": "Server Admin",
|
"serverAdmin": "Server Admin",
|
||||||
"promoteServerAdmin": "Promote to Server admin",
|
|
||||||
"promoteServerAdminTitle": "Promote to Server Admin",
|
|
||||||
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
|
|
||||||
"promoteServerAdminMessage": "Server admins have 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",
|
"managedSelfhosted": "Managed Self-Hosted",
|
||||||
"otpEnable": "Enable Two-factor",
|
"otpEnable": "Enable Two-factor",
|
||||||
"otpDisable": "Disable Two-factor",
|
"otpDisable": "Disable Two-factor",
|
||||||
@@ -2116,10 +2095,9 @@
|
|||||||
"resourceBudgetSettings": "Budget",
|
"resourceBudgetSettings": "Budget",
|
||||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarOrgs": "Organizations",
|
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
"sidebarAllUsers": "Users",
|
"sidebarAllUsers": "All Users",
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients",
|
||||||
@@ -3996,12 +3974,11 @@
|
|||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
|
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
|
||||||
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
|
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
|
||||||
"approvalsEmptyStateHowToTitle": "How to Enable",
|
|
||||||
"approvalsEmptyStateStep1Title": "Go to Roles",
|
"approvalsEmptyStateStep1Title": "Go to Roles",
|
||||||
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
|
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
|
||||||
"approvalsEmptyStateStep2Title": "Enable 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.",
|
"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",
|
"approvalsEmptyStateButtonText": "Manage Roles",
|
||||||
"domainErrorTitle": "We are having trouble verifying your domain",
|
"domainErrorTitle": "We are having trouble verifying your domain",
|
||||||
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
|
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
|
||||||
@@ -4276,11 +4253,6 @@
|
|||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||||
"resourceLauncherResourceDetails": "Resource Details",
|
"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.",
|
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/:orgId/settings/resources/client/:path*",
|
source: "/:orgId/settings/resources/client/:path*",
|
||||||
destination: "/:orgId/settings/resources/private/:path*",
|
destination: "/:orgId/settings/resources/private/:path*",
|
||||||
permanent: true
|
permanent: true
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/:orgId/settings/access/users/:userId/access-controls",
|
|
||||||
destination: "/:orgId/settings/access/users/:userId/general",
|
|
||||||
permanent: false
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5
-224
@@ -116,7 +116,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "2.23.0",
|
"@dotenvx/dotenvx": "2.23.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/ui": "^6.9.3",
|
"@react-email/ui": "^6.9.5",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tanstack/react-query-devtools": "5.102.8",
|
"@tanstack/react-query-devtools": "5.102.8",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
@@ -5219,233 +5219,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-email/ui": {
|
"node_modules/@react-email/ui": {
|
||||||
"version": "6.9.3",
|
"version": "6.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/ui/-/ui-6.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/ui/-/ui-6.9.5.tgz",
|
||||||
"integrity": "sha512-6lVPkSvsRngyPkzm9w7LeUu60vlTDs/NRSYNV2SrXnjCzDCoK0ZsACK6T521Mkn8Ojft1oYg7k+4BIy1r28OGg==",
|
"integrity": "sha512-UvE0yeSQCvdBm9BoEgvZ5vBi8abk02GbRE+4RQjjtxozy66D/O1GbUgK9RdB26+7EqQdOxdwkGLq5CMCiRO96A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"next": "16.3.0"
|
"next": "16.3.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
|
|||||||
+2
-2
@@ -96,7 +96,6 @@
|
|||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "5.4.1",
|
"js-yaml": "5.4.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lru-cache": "11.5.2",
|
|
||||||
"lucide-react": "1.38.0",
|
"lucide-react": "1.38.0",
|
||||||
"maxmind": "5.0.7",
|
"maxmind": "5.0.7",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
@@ -104,6 +103,7 @@
|
|||||||
"next-intl": "4.14.1",
|
"next-intl": "4.14.1",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "9.1.0",
|
"nodemailer": "9.1.0",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "2.23.0",
|
"@dotenvx/dotenvx": "2.23.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/ui": "^6.9.3",
|
"@react-email/ui": "^6.9.5",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tanstack/react-query-devtools": "5.102.8",
|
"@tanstack/react-query-devtools": "5.102.8",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@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
@@ -1,7 +1,13 @@
|
|||||||
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
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
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -71,7 +71,7 @@ export async function withRetry<T>(
|
|||||||
const jitter = Math.random() * baseDelay;
|
const jitter = Math.random() * baseDelay;
|
||||||
const delay = baseDelay + jitter;
|
const delay = baseDelay + jitter;
|
||||||
logger.warn(
|
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 }
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|||||||
@@ -348,8 +348,8 @@ export const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.pipe(z.string())
|
.pipe(z.string())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
subnet_group: z.string().optional().default("100.89.137.0/18"),
|
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||||
block_size: z.number().positive().gt(0).optional().default(22),
|
block_size: z.number().positive().gt(0).optional().default(24),
|
||||||
site_block_size: z
|
site_block_size: z
|
||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
|
|||||||
@@ -11,11 +11,17 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
|
||||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
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
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
@@ -295,11 +301,15 @@ export default cache;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
* 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
|
* 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.
|
* 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 {
|
class RegionalAdaptiveCache {
|
||||||
private useRedis(): boolean {
|
private useRedis(): boolean {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { db, HostMeta, sites, users } from "@server/db";
|
import { db, HostMeta, sites, users } from "@server/db";
|
||||||
import { hostMeta, licenseKey } from "@server/db";
|
import { hostMeta, licenseKey } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
import NodeCache from "node-cache";
|
||||||
import { validateJWT } from "./licenseJwt";
|
import { validateJWT } from "./licenseJwt";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -65,8 +65,8 @@ export class License {
|
|||||||
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
||||||
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
||||||
|
|
||||||
private statusCache = createLocalCache();
|
private statusCache = new NodeCache();
|
||||||
private licenseKeyCache = createLocalCache();
|
private licenseKeyCache = new NodeCache();
|
||||||
|
|
||||||
private statusKey = "status";
|
private statusKey = "status";
|
||||||
private serverSecret!: string;
|
private serverSecret!: string;
|
||||||
@@ -179,7 +179,7 @@ LQIDAQAB
|
|||||||
status.isHostLicensed = false;
|
status.isHostLicensed = false;
|
||||||
// Invalidate all and set new cache (empty)
|
// Invalidate all and set new cache (empty)
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
this.statusCache.set(this.statusKey, status, 0);
|
this.statusCache.set(this.statusKey, status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ LQIDAQAB
|
|||||||
// Invalidate old cache and set new cache
|
// Invalidate old cache and set new cache
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
for (const [key, value] of newCache.entries()) {
|
for (const [key, value] of newCache.entries()) {
|
||||||
this.licenseKeyCache.set(key, value, 0);
|
this.licenseKeyCache.set<LicenseKeyCache>(key, value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error checking license status:");
|
logger.error("Error checking license status:");
|
||||||
@@ -398,7 +398,7 @@ LQIDAQAB
|
|||||||
this.checkInProgress = false;
|
this.checkInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.statusCache.set(this.statusKey, status, 0);
|
this.statusCache.set(this.statusKey, status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2478,12 +2478,7 @@ hybridRouter.post(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(
|
logger.error(error);
|
||||||
error instanceof Error &&
|
|
||||||
error.message === "Exit node not allowed"
|
|
||||||
)) {
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
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 axios from "axios";
|
||||||
import { db, newts, sites } from "@server/db";
|
import { db, exitNodes, newts, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import redisManager from "#private/lib/redis";
|
import redisManager from "#private/lib/redis";
|
||||||
import { sendToClient } from "../ws";
|
// import { sendToClient } from "#private/routers/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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||||
@@ -42,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
|||||||
|
|
||||||
interface PendingReconnect {
|
interface PendingReconnect {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endpoint: string;
|
reachableAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory tracking for this node
|
// In-memory tracking for this node
|
||||||
@@ -56,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
|||||||
*/
|
*/
|
||||||
export async function scheduleExitNodeReconnect(
|
export async function scheduleExitNodeReconnect(
|
||||||
exitNodeId: number,
|
exitNodeId: number,
|
||||||
endpoint: string
|
reachableAt: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry: PendingReconnect = {
|
const entry: PendingReconnect = {
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
endpoint
|
reachableAt
|
||||||
};
|
};
|
||||||
|
|
||||||
pendingReconnects.set(exitNodeId, entry);
|
pendingReconnects.set(exitNodeId, entry);
|
||||||
@@ -79,8 +63,8 @@ export async function scheduleExitNodeReconnect(
|
|||||||
);
|
);
|
||||||
await redisManager.hset(
|
await redisManager.hset(
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
"endpoint",
|
"reachableAt",
|
||||||
endpoint
|
reachableAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,14 +101,14 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
`${REDIS_HASH_PREFIX}${id}`,
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
"startTime"
|
"startTime"
|
||||||
);
|
);
|
||||||
const endpoint = await redisManager.hget(
|
const reachableAt = await redisManager.hget(
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
"endpoint"
|
"reachableAt"
|
||||||
);
|
);
|
||||||
if (startTimeStr && endpoint) {
|
if (startTimeStr && reachableAt) {
|
||||||
toProcess.set(id, {
|
toProcess.set(id, {
|
||||||
startTime: parseInt(startTimeStr, 10),
|
startTime: parseInt(startTimeStr, 10),
|
||||||
endpoint
|
reachableAt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +135,7 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the exit node HTTP endpoint is reachable
|
// Check if the exit node HTTP endpoint is reachable
|
||||||
const pingUrl = `http://${entry.endpoint}/ping`;
|
const pingUrl = `${entry.reachableAt}/ping`;
|
||||||
try {
|
try {
|
||||||
await axios.get(pingUrl, { timeout: 5000 });
|
await axios.get(pingUrl, { timeout: 5000 });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -166,47 +150,47 @@ async function processPendingReconnects(): Promise<void> {
|
|||||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||||
);
|
);
|
||||||
|
|
||||||
await sendReconnectToNewts(exitNodeId);
|
// await sendReconnectToNewts(exitNodeId);
|
||||||
await removePending(exitNodeId);
|
await removePending(exitNodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||||
try {
|
// try {
|
||||||
const connectedNewts = await db
|
// const connectedNewts = await db
|
||||||
.select({ newtId: newts.newtId })
|
// .select({ newtId: newts.newtId })
|
||||||
.from(newts)
|
// .from(newts)
|
||||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||||
.where(eq(sites.exitNodeId, exitNodeId));
|
// .where(eq(sites.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
if (connectedNewts.length === 0) {
|
// if (connectedNewts.length === 0) {
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||||
);
|
// );
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
logger.info(
|
// logger.info(
|
||||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
const reconnectMessage = {
|
// const reconnectMessage = {
|
||||||
type: "newt/wg/reconnect",
|
// type: "newt/wg/reconnect",
|
||||||
data: {}
|
// data: {}
|
||||||
};
|
// };
|
||||||
|
|
||||||
await Promise.allSettled(
|
// await Promise.allSettled(
|
||||||
connectedNewts.map(({ newtId }) =>
|
// connectedNewts.map(({ newtId }) =>
|
||||||
sendToClient(newtId, reconnectMessage)
|
// sendToClient(newtId, reconnectMessage)
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
logger.error(
|
// logger.error(
|
||||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||||
{ error }
|
// { error }
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
async function removePending(exitNodeId: number): Promise<void> {
|
async function removePending(exitNodeId: number): Promise<void> {
|
||||||
pendingReconnects.delete(exitNodeId);
|
pendingReconnects.delete(exitNodeId);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
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
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
try {
|
try {
|
||||||
// Fetch the current state before updating so we can detect the offline→online transition
|
// Fetch the current state before updating so we can detect the offline→online transition
|
||||||
const [currentExitNode] = await db
|
const [currentExitNode] = await db
|
||||||
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
|
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||||
.from(exitNodes)
|
.from(exitNodes)
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -55,14 +55,12 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||||
if (
|
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||||
currentExitNode &&
|
scheduleExitNodeReconnect(
|
||||||
!currentExitNode.online &&
|
remoteExitNode.exitNodeId,
|
||||||
currentExitNode.endpoint
|
currentExitNode.reachableAt
|
||||||
) {
|
).catch((error) => {
|
||||||
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
logger.error("Failed to schedule exit node reconnect", { error });
|
||||||
exitNodeId: remoteExitNode.exitNodeId,
|
|
||||||
endpoint: currentExitNode.endpoint
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -691,9 +691,7 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
|
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
if (resourceSession) {
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession?.isRequestToken) {
|
if (resourceSession?.isRequestToken) {
|
||||||
@@ -1123,9 +1121,7 @@ async function allowAccessToken(
|
|||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
if (resourceSession) {
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -87,12 +87,6 @@ authenticated.get("/org/checkId", org.checkId);
|
|||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
|
|
||||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||||
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
|
|
||||||
authenticated.delete(
|
|
||||||
"/admin/org/:orgId",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
org.adminDeleteOrg
|
|
||||||
);
|
|
||||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -1384,12 +1378,6 @@ if (build !== "saas") {
|
|||||||
user.adminGeneratePasswordResetCode
|
user.adminGeneratePasswordResetCode
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/user/:userId/server-admin",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
user.adminSetServerAdmin
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/user/:userId",
|
"/user/:userId",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
|||||||
@@ -522,7 +522,6 @@ async function fetchLabelsForResources(
|
|||||||
type SiteGroupRow = {
|
type SiteGroupRow = {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
name: string;
|
name: string;
|
||||||
niceId: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
@@ -557,7 +556,6 @@ async function listSiteGroups(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(resources.resourceId)
|
itemCount: countDistinct(resources.resourceId)
|
||||||
@@ -578,13 +576,7 @@ async function listSiteGroups(
|
|||||||
|
|
||||||
const publicRows = await publicQuery
|
const publicRows = await publicQuery
|
||||||
.where(and(...publicConditions))
|
.where(and(...publicConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of publicRows) {
|
for (const row of publicRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -594,7 +586,6 @@ async function listSiteGroups(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -621,7 +612,6 @@ async function listSiteGroups(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(siteResources.siteResourceId)
|
itemCount: countDistinct(siteResources.siteResourceId)
|
||||||
@@ -648,13 +638,7 @@ async function listSiteGroups(
|
|||||||
|
|
||||||
const siteRows = await siteResourceQuery
|
const siteRows = await siteResourceQuery
|
||||||
.where(and(...siteConditions))
|
.where(and(...siteConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of siteRows) {
|
for (const row of siteRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -664,7 +648,6 @@ async function listSiteGroups(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
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(
|
async function mapPublicResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceIds: number[],
|
resourceIds: number[],
|
||||||
@@ -1138,7 +1084,6 @@ async function mapPublicResources(
|
|||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
exitNodeEndpoint: exitNodes.endpoint
|
exitNodeEndpoint: exitNodes.endpoint
|
||||||
@@ -1152,65 +1097,56 @@ async function mapPublicResources(
|
|||||||
inArray(resources.resourceId, resourceIds),
|
inArray(resources.resourceId, resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true),
|
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 seen = new Set<string>();
|
||||||
const siteIdsByKey = new Map<string, Set<number>>();
|
const result: LauncherResource[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `public:${row.resourceId}`;
|
const key = `public:${row.resourceId}`;
|
||||||
let item = byKey.get(key);
|
if (seen.has(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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
const access = formatPublicResourceAccess({
|
||||||
if (seenSiteIds.has(site.siteId)) {
|
mode: row.mode,
|
||||||
continue;
|
fullDomain: row.fullDomain,
|
||||||
}
|
ssl: row.ssl,
|
||||||
seenSiteIds.add(site.siteId);
|
proxyPort: row.proxyPort,
|
||||||
item.sites.push(site);
|
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()) {
|
return result;
|
||||||
item.sites.sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
||||||
);
|
|
||||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapSiteResources(
|
async function mapSiteResources(
|
||||||
@@ -1239,7 +1175,6 @@ async function mapSiteResources(
|
|||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online
|
siteOnline: sites.online
|
||||||
})
|
})
|
||||||
@@ -1254,69 +1189,59 @@ async function mapSiteResources(
|
|||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
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 seen = new Set<string>();
|
||||||
const siteIdsByKey = new Map<string, Set<number>>();
|
const result: LauncherResource[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `site:${row.siteResourceId}`;
|
const key = `site:${row.siteResourceId}`;
|
||||||
let item = byKey.get(key);
|
if (seen.has(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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
const access = formatSiteResourceAccess({
|
||||||
if (seenSiteIds.has(site.siteId)) {
|
mode: row.mode,
|
||||||
continue;
|
destination: row.destination,
|
||||||
}
|
destinationPort: row.destinationPort,
|
||||||
seenSiteIds.add(site.siteId);
|
scheme: row.scheme,
|
||||||
item.sites.push(site);
|
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()) {
|
return result;
|
||||||
item.sites.sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
||||||
);
|
|
||||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterResourcesBySite(
|
function filterResourcesBySite(
|
||||||
@@ -1327,17 +1252,13 @@ function filterResourcesBySite(
|
|||||||
return items.filter((item) => item.mode === "inference");
|
return items.filter((item) => item.mode === "inference");
|
||||||
}
|
}
|
||||||
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
|
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
|
||||||
return items.filter(
|
return items.filter((item) => !item.site && item.mode !== "inference");
|
||||||
(item) => item.sites.length === 0 && item.mode !== "inference"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const siteId = Number.parseInt(groupKey, 10);
|
const siteId = Number.parseInt(groupKey, 10);
|
||||||
if (!Number.isFinite(siteId)) {
|
if (!Number.isFinite(siteId)) {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
return items.filter((item) =>
|
return items.filter((item) => item.site?.siteId === siteId);
|
||||||
item.sites.some((site) => site.siteId === siteId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterResourcesByLabel(
|
function filterResourcesByLabel(
|
||||||
@@ -1578,7 +1499,6 @@ async function collectAccessibleSites(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(resources.resourceId)
|
itemCount: countDistinct(resources.resourceId)
|
||||||
@@ -1587,13 +1507,7 @@ async function collectAccessibleSites(
|
|||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.where(and(...publicConditions))
|
.where(and(...publicConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of publicRows) {
|
for (const row of publicRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -1603,7 +1517,6 @@ async function collectAccessibleSites(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -1627,7 +1540,6 @@ async function collectAccessibleSites(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(siteResources.siteResourceId)
|
itemCount: countDistinct(siteResources.siteResourceId)
|
||||||
@@ -1639,13 +1551,7 @@ async function collectAccessibleSites(
|
|||||||
)
|
)
|
||||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
.where(and(...siteConditions))
|
.where(and(...siteConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of siteRows) {
|
for (const row of siteRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -1655,7 +1561,6 @@ async function collectAccessibleSites(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -1770,7 +1675,6 @@ export async function listAccessibleLauncherSitesForUser(
|
|||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online
|
online: row.online
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export type LauncherLabel = {
|
|||||||
export type LauncherSiteInfo = {
|
export type LauncherSiteInfo = {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
name: string;
|
name: string;
|
||||||
niceId: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
};
|
};
|
||||||
@@ -52,7 +51,6 @@ export type LauncherResource = {
|
|||||||
mode: string;
|
mode: string;
|
||||||
labels: LauncherLabel[];
|
labels: LauncherLabel[];
|
||||||
site?: LauncherSiteInfo;
|
site?: LauncherSiteInfo;
|
||||||
sites: LauncherSiteInfo[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherGroup = {
|
export type LauncherGroup = {
|
||||||
@@ -186,7 +184,8 @@ export function parseIdListParam(value: string | undefined): number[] {
|
|||||||
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||||
|
|
||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
{ type: "default" } | { type: "saved"; viewId: number };
|
| { type: "default" }
|
||||||
|
| { type: "saved"; viewId: number };
|
||||||
|
|
||||||
export type LauncherScaleCapabilities = {
|
export type LauncherScaleCapabilities = {
|
||||||
allowSiteGrouping: boolean;
|
allowSiteGrouping: boolean;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!resources || resources.length === 0) {
|
if (!resources || resources.length === 0) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
|
|
||||||
if (resources.length > 1) {
|
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
|
// 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`
|
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (currentResourceAssociationCaches.length === 0) {
|
if (currentResourceAssociationCaches.length === 0) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.networkId) {
|
if (!resource.networkId) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||||
|
|
||||||
if (!siteRows || siteRows.length === 0) {
|
if (!siteRows || siteRows.length === 0) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
@@ -164,7 +164,9 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sitesToProcess.length === 0) {
|
if (sitesToProcess.length === 0) {
|
||||||
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
logger.error(
|
||||||
|
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||||
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -191,7 +193,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -203,7 +205,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -227,7 +229,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!handshakeInitiated) {
|
if (!handshakeInitiated) {
|
||||||
logger.warn(
|
logger.error(
|
||||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||||
);
|
);
|
||||||
await sendCancel();
|
await sendCancel();
|
||||||
|
|||||||
@@ -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..."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,5 +9,3 @@ export * from "./listOrgs";
|
|||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
export * from "./checkOrgUserAccess";
|
export * from "./checkOrgUserAccess";
|
||||||
export * from "./resetOrgBandwidth";
|
export * from "./resetOrgBandwidth";
|
||||||
export * from "./adminListOrgs";
|
|
||||||
export * from "./adminDeleteOrg";
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
|
|||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -88,15 +88,6 @@ const listUsersSchema = z.strictObject({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
description:
|
description:
|
||||||
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
|
"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,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
idp_id,
|
idp_id,
|
||||||
two_factor: twoFactorFilter,
|
two_factor: twoFactorFilter
|
||||||
server_admin: serverAdminFilter
|
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
if (typeof idp_id === "number") {
|
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) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
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 whereClause = and(...conditions);
|
||||||
|
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export * from "./adminListUsers";
|
|||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
export * from "./adminGeneratePasswordResetCode";
|
export * from "./adminGeneratePasswordResetCode";
|
||||||
export * from "./adminSetServerAdmin";
|
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
export * from "./removeInvitation";
|
||||||
export * from "./createOrgUser";
|
export * from "./createOrgUser";
|
||||||
|
|||||||
@@ -95,34 +95,22 @@ const listUsersSchema = z.strictObject({
|
|||||||
'Filter by identity provider id, or "internal" for internal users'
|
'Filter by identity provider id, or "internal" for internal users'
|
||||||
}),
|
}),
|
||||||
role_id: z
|
role_id: z
|
||||||
.preprocess(
|
.preprocess((val) => {
|
||||||
(val) => {
|
if (val === undefined || val === null || val === "") {
|
||||||
if (val === undefined || val === null || val === "") {
|
return undefined;
|
||||||
return undefined;
|
}
|
||||||
}
|
const raw = Array.isArray(val) ? val : [val];
|
||||||
const raw = Array.isArray(val) ? val : [val];
|
const nums = raw
|
||||||
const includeOwner = raw.some((v) => v === "owner");
|
.map((v) =>
|
||||||
const nums = raw
|
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
||||||
.map((v) =>
|
)
|
||||||
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
)
|
const unique = [...new Set(nums)];
|
||||||
.filter((n) => Number.isInteger(n) && n > 0);
|
return unique.length ? unique : undefined;
|
||||||
const unique = [...new Set(nums)];
|
}, z.array(z.number().int().positive()).optional())
|
||||||
if (!unique.length && !includeOwner) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return { roleIds: unique, includeOwner };
|
|
||||||
},
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
roleIds: z.array(z.number().int().positive()),
|
|
||||||
includeOwner: z.boolean()
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
)
|
|
||||||
.openapi({
|
.openapi({
|
||||||
description:
|
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 } =
|
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
const roleIds = role_id?.roleIds ?? [];
|
const roleIds = role_id ?? [];
|
||||||
const includeOwner = role_id?.includeOwner ?? false;
|
|
||||||
|
|
||||||
const parsedParams = listUsersParamsSchema.safeParse(req.params);
|
const parsedParams = listUsersParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -280,35 +267,21 @@ export async function listUsers(
|
|||||||
conditions.push(eq(users.idpId, idp_id));
|
conditions.push(eq(users.idpId, idp_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleIds.length > 0 || includeOwner) {
|
if (roleIds.length > 0) {
|
||||||
const roleFilterParts = [];
|
conditions.push(
|
||||||
|
exists(
|
||||||
if (includeOwner) {
|
db
|
||||||
roleFilterParts.push(eq(userOrgs.isOwner, true));
|
.select()
|
||||||
}
|
.from(userOrgRoles)
|
||||||
|
.where(
|
||||||
if (roleIds.length > 0) {
|
and(
|
||||||
roleFilterParts.push(
|
eq(userOrgRoles.userId, users.userId),
|
||||||
exists(
|
eq(userOrgRoles.orgId, orgId),
|
||||||
db
|
inArray(userOrgRoles.roleId, roleIds)
|
||||||
.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(
|
const countQuery = db.$count(
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
|
|||||||
subscriptionStatus = subRes.data.data;
|
subscriptionStatus = subRes.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If subscription fetch fails, keep subscriptionStatus as null
|
// If subscription fetch fails, keep subscriptionStatus as null
|
||||||
// console.error("Failed to fetch subscription status:", error);
|
console.error("Failed to fetch subscription status:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import OrgRouteLoading from "@app/components/OrgRouteLoading";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function OrgPageLoading() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -38,7 +38,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function AccessControlsPage() {
|
||||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||||
const { user: sessionUser } = useUserContext();
|
const { user: sessionUser } = useUserContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -57,7 +57,7 @@ export default function GeneralPage() {
|
|||||||
(build === "enterprise" && !isPaid) ||
|
(build === "enterprise" && !isPaid) ||
|
||||||
(build === "oss" && !isPaid));
|
(build === "oss" && !isPaid));
|
||||||
|
|
||||||
const generalFormSchema = z.object({
|
const accessControlsFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
autoProvisioned: z.boolean(),
|
autoProvisioned: z.boolean(),
|
||||||
roles: z
|
roles: z
|
||||||
@@ -72,7 +72,7 @@ export default function GeneralPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(generalFormSchema),
|
resolver: zodResolver(accessControlsFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: user.username!,
|
username: user.username!,
|
||||||
autoProvisioned: user.autoProvisioned || false,
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
@@ -196,9 +196,11 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
|
{t("accessControls")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("userGeneralSettingsDescription")}
|
{t("accessControlsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
@@ -206,9 +208,11 @@ export default function GeneralPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void handleGeneralSubmit(e)}
|
onSubmit={(e) =>
|
||||||
|
void handleAccessControlsSubmit(e)
|
||||||
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="user-general-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
{user.type !== UserType.Internal &&
|
{user.type !== UserType.Internal &&
|
||||||
user.idpType && (
|
user.idpType && (
|
||||||
@@ -277,9 +281,9 @@ export default function GeneralPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
form="user-general-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("accessControlsSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -9,16 +9,15 @@ import { cache } from "react";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "User"
|
title: "User"
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserLayoutProps = {
|
interface UserLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ userId: string; orgId: string }>;
|
params: Promise<{ userId: string; orgId: string }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function UserLayoutProps(props: UserLayoutProps) {
|
export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -43,23 +42,15 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("accessControls"),
|
||||||
href: "/{orgId}/settings/access/users/{userId}/general"
|
href: "/{orgId}/settings/access/users/{userId}/access-controls"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={
|
title={`${user?.email}`}
|
||||||
user
|
|
||||||
? getUserDisplayName({
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
username: user.username
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
description={t("userDescription2")}
|
description={t("userDescription2")}
|
||||||
/>
|
/>
|
||||||
<OrgUserProvider orgUser={user}>
|
<OrgUserProvider orgUser={user}>
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default async function UserPage(props: {
|
|||||||
params: Promise<{ orgId: string; userId: string }>;
|
params: Promise<{ orgId: string; userId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { orgId, userId } = await props.params;
|
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";
|
"use client";
|
||||||
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
SettingsContainer,
|
||||||
CredenzaBody,
|
SettingsSection,
|
||||||
CredenzaContent,
|
SettingsSectionBody,
|
||||||
CredenzaDescription,
|
SettingsSectionDescription,
|
||||||
CredenzaFooter,
|
SettingsSectionForm,
|
||||||
CredenzaHeader,
|
SettingsSectionHeader,
|
||||||
CredenzaTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Settings";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useActionState, useRef, useState, startTransition } from "react";
|
import {
|
||||||
|
useActionState,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
startTransition
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -38,6 +42,7 @@ import { AxiosResponse } from "axios";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
@@ -50,15 +55,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||||
import {
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
type UserType = "internal" | "oidc";
|
type UserType = "internal" | "oidc";
|
||||||
|
|
||||||
@@ -99,7 +96,6 @@ export default function Page() {
|
|||||||
"internal"
|
"internal"
|
||||||
);
|
);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
@@ -250,9 +246,9 @@ export default function Page() {
|
|||||||
build === "saas" || env.app.identityProviderMode === "org";
|
build === "saas" || env.app.identityProviderMode === "org";
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.get<AxiosResponse<ListIdpsResponse>>(
|
.get<
|
||||||
useOrgIdps ? `/org/${orgId}/idp` : "/idp"
|
AxiosResponse<ListIdpsResponse>
|
||||||
)
|
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
@@ -354,13 +350,13 @@ export default function Page() {
|
|||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
setInviteLink(res.data.data.inviteLink);
|
setInviteLink(res.data.data.inviteLink);
|
||||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
|
||||||
setIsInviteDialogOpen(true);
|
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: t("userInvited"),
|
title: t("userInvited"),
|
||||||
description: t("userInvitedDescription")
|
description: t("userInvitedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +460,6 @@ export default function Page() {
|
|||||||
setSendEmail(env.email.emailEnabled);
|
setSendEmail(env.email.emailEnabled);
|
||||||
internalForm.reset();
|
internalForm.reset();
|
||||||
setInviteLink(null);
|
setInviteLink(null);
|
||||||
setIsInviteDialogOpen(false);
|
|
||||||
setExpiresInDays(1);
|
setExpiresInDays(1);
|
||||||
} else {
|
} else {
|
||||||
googleAzureForm.reset();
|
googleAzureForm.reset();
|
||||||
@@ -491,7 +486,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{userOptions.length > 1 ? (
|
{!inviteLink && userOptions.length > 1 ? (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -513,132 +508,177 @@ export default function Page() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedOption === "internal" && dataLoaded && (
|
{selectedOption === "internal" && dataLoaded && (
|
||||||
<SettingsSection>
|
<>
|
||||||
<SettingsSectionHeader>
|
{!inviteLink ? (
|
||||||
<SettingsSectionTitle>
|
<SettingsSection>
|
||||||
{t("userSettings")}
|
<SettingsSectionHeader>
|
||||||
</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
{t("userSettings")}
|
||||||
{t("userSettingsDescription")}
|
</SettingsSectionTitle>
|
||||||
</SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
{t("userSettingsDescription")}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionDescription>
|
||||||
<SettingsSectionForm>
|
</SettingsSectionHeader>
|
||||||
<Form {...internalForm}>
|
<SettingsSectionBody>
|
||||||
<form
|
<SettingsSectionForm>
|
||||||
onSubmit={(e) => {
|
<Form {...internalForm}>
|
||||||
e.preventDefault();
|
<form
|
||||||
startTransition(() => {
|
onSubmit={(e) => {
|
||||||
submitInternalAction();
|
e.preventDefault();
|
||||||
});
|
startTransition(() => {
|
||||||
}}
|
submitInternalAction();
|
||||||
className="space-y-4"
|
});
|
||||||
id="create-user-form"
|
}}
|
||||||
>
|
className="space-y-4"
|
||||||
<FormField
|
id="create-user-form"
|
||||||
control={internalForm.control}
|
>
|
||||||
name="email"
|
<FormField
|
||||||
render={({ field }) => (
|
control={
|
||||||
<FormItem>
|
internalForm.control
|
||||||
<FormLabel>
|
}
|
||||||
{t("email")}
|
name="email"
|
||||||
</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input {...field} />
|
<FormLabel>
|
||||||
</FormControl>
|
{t("email")}
|
||||||
<FormMessage />
|
</FormLabel>
|
||||||
</FormItem>
|
<FormControl>
|
||||||
)}
|
<Input
|
||||||
/>
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{env.email.emailEnabled && (
|
<FormField
|
||||||
<div className="flex items-center space-x-2">
|
control={
|
||||||
<Checkbox
|
internalForm.control
|
||||||
id="send-email"
|
}
|
||||||
checked={sendEmail}
|
name="validForHours"
|
||||||
onCheckedChange={(e) =>
|
render={({ field }) => (
|
||||||
setSendEmail(
|
<FormItem>
|
||||||
e as boolean
|
<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
|
{env.email.emailEnabled && (
|
||||||
control={internalForm.control}
|
<div className="flex items-center space-x-2">
|
||||||
name="validForHours"
|
<Checkbox
|
||||||
render={({ field }) => (
|
id="send-email"
|
||||||
<FormItem>
|
checked={
|
||||||
<FormLabel>
|
sendEmail
|
||||||
{t("inviteValid")}
|
}
|
||||||
</FormLabel>
|
onCheckedChange={(
|
||||||
<Select
|
e
|
||||||
onValueChange={
|
) =>
|
||||||
field.onChange
|
setSendEmail(
|
||||||
}
|
e as boolean
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="send-email"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"inviteEmailSent"
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</label>
|
||||||
</Select>
|
</div>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
</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}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<OrgRolesTagField
|
</SettingsSectionBody>
|
||||||
form={internalForm}
|
</SettingsSection>
|
||||||
name="roles"
|
)}
|
||||||
orgId={orgId as string}
|
</>
|
||||||
supportsMultipleRolesPerUser={
|
|
||||||
supportsMultipleRolesPerUser
|
|
||||||
}
|
|
||||||
showMultiRolePaywallMessage={
|
|
||||||
showMultiRolePaywallMessage
|
|
||||||
}
|
|
||||||
paywallMessage={
|
|
||||||
invitePaywallMessage
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedOption &&
|
{selectedOption &&
|
||||||
@@ -858,56 +898,24 @@ export default function Page() {
|
|||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
{selectedOption && dataLoaded && (
|
{selectedOption && dataLoaded && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type={inviteLink ? "button" : "submit"}
|
||||||
form="create-user-form"
|
form={inviteLink ? undefined : "create-user-form"}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
onClick={
|
||||||
|
inviteLink
|
||||||
|
? () =>
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/access/users`
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("accessUserCreate")}
|
{inviteLink ? t("done") : t("accessUserCreate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,13 +78,12 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||||||
rolesRes && rolesRes.status === 200
|
rolesRes && rolesRes.status === 200
|
||||||
? (rolesRes.data.data.roles ?? [])
|
? (rolesRes.data.data.roles ?? [])
|
||||||
: [];
|
: [];
|
||||||
const roleFilterOptions = [
|
const roleFilterOptions = orgRoles.map(
|
||||||
{ value: "owner", label: t("accessRoleOwner") },
|
(r: ListRolesResponse["roles"][number]) => ({
|
||||||
...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({
|
|
||||||
value: String(r.roleId),
|
value: String(r.roleId),
|
||||||
label: r.name
|
label: r.name
|
||||||
}))
|
})
|
||||||
];
|
);
|
||||||
|
|
||||||
const invitationsRes = await internal
|
const invitationsRes = await internal
|
||||||
.get(
|
.get(
|
||||||
@@ -127,12 +126,14 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||||||
idpId: user.idpId,
|
idpId: user.idpId,
|
||||||
idpName: user.idpName || t("idpNameInternal"),
|
idpName: user.idpName || t("idpNameInternal"),
|
||||||
status: t("userConfirmed"),
|
status: t("userConfirmed"),
|
||||||
roleLabels: (() => {
|
roleLabels: user.isOwner
|
||||||
const names = (user.roles ?? [])
|
? [t("accessRoleOwner")]
|
||||||
.map((r) => r.roleName)
|
: (() => {
|
||||||
.filter((n): n is string => Boolean(n?.length));
|
const names = (user.roles ?? [])
|
||||||
return names.length ? names : [t("accessRoleMember")];
|
.map((r) => r.roleName)
|
||||||
})(),
|
.filter((n): n is string => Boolean(n?.length));
|
||||||
|
return names.length ? names : [t("accessRoleMember")];
|
||||||
|
})(),
|
||||||
isOwner: user.isOwner || false
|
isOwner: user.isOwner || false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,18 +43,10 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -66,7 +58,6 @@ export default function Page() {
|
|||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -84,6 +75,22 @@ export default function Page() {
|
|||||||
|
|
||||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
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({
|
const form = useForm({
|
||||||
resolver: zodResolver(createFormSchema),
|
resolver: zodResolver(createFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -91,9 +98,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToApiKeysList() {
|
const copiedForm = useForm({
|
||||||
router.push(`/${orgId}/settings/api-keys`);
|
resolver: zodResolver(copiedFormSchema),
|
||||||
}
|
defaultValues: {
|
||||||
|
copied: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit(data: CreateFormValues) {
|
async function onSubmit(data: CreateFormValues) {
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -103,10 +113,9 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<AxiosResponse<CreateOrgApiKeyResponse>>(
|
.put<
|
||||||
`/org/${orgId}/api-key/`,
|
AxiosResponse<CreateOrgApiKeyResponse>
|
||||||
payload
|
>(`/org/${orgId}/api-key/`, payload)
|
||||||
)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -116,10 +125,16 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
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
|
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(
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
(key) => selectedPermissions[key]
|
(key) => selectedPermissions[key]
|
||||||
)
|
)
|
||||||
@@ -134,14 +149,27 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (actionsRes) {
|
if (actionsRes) {
|
||||||
setApiKey(created);
|
setApiKey(data);
|
||||||
setIsApiKeyDialogOpen(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCreateLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadingPage(false);
|
setLoadingPage(false);
|
||||||
@@ -157,7 +185,12 @@ export default function Page() {
|
|||||||
title={t("apiKeysCreate")}
|
title={t("apiKeysCreate")}
|
||||||
description={t("apiKeysCreateDescription")}
|
description={t("apiKeysCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" onClick={goToApiKeysList}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("apiKeysSeeAll")}
|
{t("apiKeysSeeAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,152 +198,206 @@ export default function Page() {
|
|||||||
{!loadingPage && (
|
{!loadingPage && (
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
{!apiKey && (
|
||||||
<SettingsSectionHeader>
|
<>
|
||||||
<SettingsSectionTitle>
|
<SettingsSection>
|
||||||
{t("apiKeysTitle")}
|
<SettingsSectionHeader>
|
||||||
</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
{t("apiKeysTitle")}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionForm>
|
</SettingsSectionHeader>
|
||||||
<Form {...form}>
|
<SettingsSectionBody>
|
||||||
<form
|
<SettingsSectionForm>
|
||||||
onKeyDown={(e) => {
|
<Form {...form}>
|
||||||
if (e.key === "Enter") {
|
<form
|
||||||
e.preventDefault();
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === "Enter") {
|
||||||
}}
|
e.preventDefault(); // block default enter refresh
|
||||||
className="space-y-4"
|
}
|
||||||
id="create-site-form"
|
}}
|
||||||
>
|
className="space-y-4"
|
||||||
<FormField
|
id="create-site-form"
|
||||||
control={form.control}
|
>
|
||||||
name="name"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
{t("name")}
|
<FormItem>
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{t("name")}
|
||||||
<Input
|
</FormLabel>
|
||||||
autoComplete="off"
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
/>
|
autoComplete="off"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</form>
|
)}
|
||||||
</Form>
|
/>
|
||||||
</SettingsSectionForm>
|
</form>
|
||||||
</SettingsSectionBody>
|
</Form>
|
||||||
</SettingsSection>
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("apiKeysGeneralSettings")}
|
{t("apiKeysGeneralSettings")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("apiKeysGeneralSettingsDescription")}
|
{t(
|
||||||
</SettingsSectionDescription>
|
"apiKeysGeneralSettingsDescription"
|
||||||
</SettingsSectionHeader>
|
)}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionDescription>
|
||||||
<PermissionsSelectBox
|
</SettingsSectionHeader>
|
||||||
selectedPermissions={selectedPermissions}
|
<SettingsSectionBody>
|
||||||
onChange={setSelectedPermissions}
|
<PermissionsSelectBox
|
||||||
/>
|
selectedPermissions={
|
||||||
</SettingsSectionBody>
|
selectedPermissions
|
||||||
</SettingsSection>
|
}
|
||||||
|
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>
|
</SettingsContainer>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
<Button
|
{!apiKey && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={createLoading || apiKey !== null}
|
variant="outline"
|
||||||
onClick={goToApiKeysList}
|
disabled={createLoading || apiKey !== null}
|
||||||
>
|
onClick={() => {
|
||||||
{t("cancel")}
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
</Button>
|
}}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{t("cancel")}
|
||||||
loading={createLoading}
|
</Button>
|
||||||
disabled={createLoading || apiKey !== null}
|
)}
|
||||||
onClick={() => {
|
{!apiKey && (
|
||||||
form.handleSubmit(onSubmit)();
|
<Button
|
||||||
}}
|
type="button"
|
||||||
>
|
loading={createLoading}
|
||||||
{t("generate")}
|
disabled={createLoading || apiKey !== null}
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("done")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
colSpan={columns.length}
|
||||||
message={emptyMessage ?? t("targetNoOne")}
|
message={emptyMessage ?? t("targetNoOne")}
|
||||||
action={addTargetButton}
|
action={addTargetButton}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -46,15 +46,6 @@ import moment from "moment";
|
|||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -65,7 +56,6 @@ export default function Page() {
|
|||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -83,6 +73,22 @@ export default function Page() {
|
|||||||
|
|
||||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
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({
|
const form = useForm({
|
||||||
resolver: zodResolver(createFormSchema),
|
resolver: zodResolver(createFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -90,9 +96,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToApiKeysList() {
|
const copiedForm = useForm({
|
||||||
router.push(`/admin/api-keys`);
|
resolver: zodResolver(copiedFormSchema),
|
||||||
}
|
defaultValues: {
|
||||||
|
copied: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit(data: CreateFormValues) {
|
async function onSubmit(data: CreateFormValues) {
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -112,10 +121,16 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
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
|
const actionsRes = await api
|
||||||
.post(`/api-key/${created.apiKeyId}/actions`, {
|
.post(`/api-key/${data.apiKeyId}/actions`, {
|
||||||
actionIds: Object.keys(selectedPermissions).filter(
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
(key) => selectedPermissions[key]
|
(key) => selectedPermissions[key]
|
||||||
)
|
)
|
||||||
@@ -130,14 +145,21 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (actionsRes) {
|
if (actionsRes) {
|
||||||
setApiKey(created);
|
setApiKey(data);
|
||||||
setIsApiKeyDialogOpen(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopiedSubmit(data: CopiedFormValues) {
|
||||||
|
if (!data.copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadingPage(false);
|
setLoadingPage(false);
|
||||||
@@ -153,7 +175,12 @@ export default function Page() {
|
|||||||
title={t("apiKeysCreate")}
|
title={t("apiKeysCreate")}
|
||||||
description={t("apiKeysCreateDescription")}
|
description={t("apiKeysCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" onClick={goToApiKeysList}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("apiKeysSeeAll")}
|
{t("apiKeysSeeAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,153 +188,207 @@ export default function Page() {
|
|||||||
{!loadingPage && (
|
{!loadingPage && (
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
{!apiKey && (
|
||||||
<SettingsSectionHeader>
|
<>
|
||||||
<SettingsSectionTitle>
|
<SettingsSection>
|
||||||
{t("apiKeysTitle")}
|
<SettingsSectionHeader>
|
||||||
</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
{t("apiKeysTitle")}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionForm>
|
</SettingsSectionHeader>
|
||||||
<Form {...form}>
|
<SettingsSectionBody>
|
||||||
<form
|
<SettingsSectionForm>
|
||||||
onKeyDown={(e) => {
|
<Form {...form}>
|
||||||
if (e.key === "Enter") {
|
<form
|
||||||
e.preventDefault();
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === "Enter") {
|
||||||
}}
|
e.preventDefault(); // block default enter refresh
|
||||||
className="space-y-4"
|
}
|
||||||
id="create-site-form"
|
}}
|
||||||
>
|
className="space-y-4"
|
||||||
<FormField
|
id="create-site-form"
|
||||||
control={form.control}
|
>
|
||||||
name="name"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
{t("name")}
|
<FormItem>
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{t("name")}
|
||||||
<Input
|
</FormLabel>
|
||||||
autoComplete="off"
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
/>
|
autoComplete="off"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</form>
|
)}
|
||||||
</Form>
|
/>
|
||||||
</SettingsSectionForm>
|
</form>
|
||||||
</SettingsSectionBody>
|
</Form>
|
||||||
</SettingsSection>
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("apiKeysGeneralSettings")}
|
{t("apiKeysGeneralSettings")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("apiKeysGeneralSettingsDescription")}
|
{t(
|
||||||
</SettingsSectionDescription>
|
"apiKeysGeneralSettingsDescription"
|
||||||
</SettingsSectionHeader>
|
)}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionDescription>
|
||||||
<PermissionsSelectBox
|
</SettingsSectionHeader>
|
||||||
root={true}
|
<SettingsSectionBody>
|
||||||
selectedPermissions={selectedPermissions}
|
<PermissionsSelectBox
|
||||||
onChange={setSelectedPermissions}
|
root={true}
|
||||||
/>
|
selectedPermissions={
|
||||||
</SettingsSectionBody>
|
selectedPermissions
|
||||||
</SettingsSection>
|
}
|
||||||
|
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>
|
</SettingsContainer>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
<Button
|
{!apiKey && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={createLoading || apiKey !== null}
|
variant="outline"
|
||||||
onClick={goToApiKeysList}
|
disabled={createLoading || apiKey !== null}
|
||||||
>
|
onClick={() => {
|
||||||
{t("cancel")}
|
router.push(`/admin/api-keys`);
|
||||||
</Button>
|
}}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{t("cancel")}
|
||||||
loading={createLoading}
|
</Button>
|
||||||
disabled={createLoading || apiKey !== null}
|
)}
|
||||||
onClick={() => {
|
{!apiKey && (
|
||||||
form.handleSubmit(onSubmit)();
|
<Button
|
||||||
}}
|
type="button"
|
||||||
>
|
loading={createLoading}
|
||||||
{t("generate")}
|
disabled={createLoading || apiKey !== null}
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("done")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -65,7 +65,7 @@
|
|||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: #0c0c0d;
|
--sidebar: #0C0C0D;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.646 0.222 41.116);
|
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Building2Icon,
|
|
||||||
Cable,
|
Cable,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -377,11 +376,6 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
href: "/admin/users",
|
href: "/admin/users",
|
||||||
icon: <Users className="size-4 flex-none" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "sidebarOrgs",
|
|
||||||
href: "/admin/organizations",
|
|
||||||
icon: <Building2Icon className="size-4 flex-none" />
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/admin/api-keys",
|
href: "/admin/api-keys",
|
||||||
@@ -398,7 +392,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(build === "enterprise"
|
...(build == "enterprise"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarLicense",
|
title: "sidebarLicense",
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
ShieldUserIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -44,13 +43,6 @@ import {
|
|||||||
CredenzaClose
|
CredenzaClose
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "./ui/tooltip";
|
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
|
||||||
|
|
||||||
export type GlobalUserRow = {
|
export type GlobalUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -98,11 +90,6 @@ export default function UsersTable({
|
|||||||
const [passwordResetCodeData, setPasswordResetCodeData] =
|
const [passwordResetCodeData, setPasswordResetCodeData] =
|
||||||
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
||||||
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
||||||
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
|
|
||||||
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
|
|
||||||
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
|
|
||||||
const [demoting, setDemoting] = useState<GlobalUserRow | null>(null);
|
|
||||||
const user = useUserContext();
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const {
|
const {
|
||||||
@@ -122,11 +109,6 @@ export default function UsersTable({
|
|||||||
.optional()
|
.optional()
|
||||||
.catch(undefined);
|
.catch(undefined);
|
||||||
|
|
||||||
const serverAdminFilterSchema = z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.optional()
|
|
||||||
.catch(undefined);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
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) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
filter({
|
filter({
|
||||||
@@ -301,32 +235,7 @@ export default function UsersTable({
|
|||||||
<Icon className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="inline-flex gap-1 items-center">
|
|
||||||
{row.original.username}{" "}
|
|
||||||
{row.original.id === user.user.userId && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
·
|
|
||||||
</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",
|
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",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -491,34 +369,11 @@ export default function UsersTable({
|
|||||||
{t("generatePasswordResetCode")}
|
{t("generatePasswordResetCode")}
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="text-red-400"
|
|
||||||
>
|
>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</DropdownMenuItem>
|
</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
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={users}
|
rows={users}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
|||||||
<div className="inline-flex items-start md:items-center gap-2">
|
<div className="inline-flex items-start md:items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
<Link
|
<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"
|
className="text-primary hover:underline cursor-pointer"
|
||||||
>
|
>
|
||||||
{getUserDisplayName({
|
{getUserDisplayName({
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
ShieldCheck,
|
||||||
SettingsSectionBody,
|
Check,
|
||||||
SettingsSectionFooter
|
Ban,
|
||||||
} from "@app/components/Settings";
|
User,
|
||||||
import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
|
Settings,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -18,50 +21,102 @@ export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsSectionBody>
|
<Card>
|
||||||
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
|
<CardContent className="p-6 md:p-12">
|
||||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto">
|
||||||
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
|
<div className="space-y-2">
|
||||||
{t("approvalsEmptyStateTitle")}
|
<h3 className="text-xl md:text-2xl font-semibold">
|
||||||
</h2>
|
{t("approvalsEmptyStateTitle")}
|
||||||
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
|
</h3>
|
||||||
{t("approvalsEmptyStateDescription")}
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
</p>
|
{t("approvalsEmptyStateDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 w-full max-w-lg text-left space-y-3">
|
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4">
|
||||||
<p className="text-sm font-medium text-center">
|
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border">
|
||||||
{t("approvalsEmptyStateHowToTitle")}
|
<div className="flex items-start gap-3 md:gap-4">
|
||||||
</p>
|
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
|
||||||
<ul className="text-sm text-muted-foreground space-y-2">
|
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
|
||||||
<li className="flex items-start gap-2">
|
</div>
|
||||||
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
<div className="flex-1 text-left min-w-0">
|
||||||
<span>
|
<h4 className="font-semibold mb-1 text-sm md:text-base">
|
||||||
{t("approvalsEmptyStateStep1Description")}
|
{t("approvalsEmptyStateStep1Title")}
|
||||||
</span>
|
</h4>
|
||||||
</li>
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
<li className="flex items-start gap-2">
|
{t(
|
||||||
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
"approvalsEmptyStateStep1Description"
|
||||||
<span>
|
)}
|
||||||
{t("approvalsEmptyStateStep2Description")}
|
</p>
|
||||||
</span>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
|
<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>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
|
</Card>
|
||||||
{t("approvalsEmptyStatePreviewDescription")}
|
</div>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSub,
|
DropdownMenuTrigger
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} 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 { useTransition } from "react";
|
||||||
import { Locale } from "@/i18n/config";
|
import { Locale } from "@/i18n/config";
|
||||||
import { setUserLocale } from "@/services/locale";
|
import { setUserLocale } from "@/services/locale";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
@@ -42,31 +43,36 @@ export default function LocaleSwitcherSelect({
|
|||||||
const selected = items.find((item) => item.value === defaultValue);
|
const selected = items.find((item) => item.value === defaultValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuSub>
|
<DropdownMenu>
|
||||||
<DropdownMenuSubTrigger
|
<DropdownMenuTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
variant="ghost"
|
||||||
isPending && "pointer-events-none"
|
className={clsx(
|
||||||
)}
|
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
|
||||||
aria-label={label}
|
isPending && "pointer-events-none"
|
||||||
>
|
)}
|
||||||
<Languages className="mr-2 h-4 w-4" />
|
aria-label={label}
|
||||||
<span>{selected?.label ?? label}</span>
|
>
|
||||||
</DropdownMenuSubTrigger>
|
<Languages className="text-muted-foreground h-4 w-4" />
|
||||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
<span className="text-left flex-1">
|
||||||
|
{selected?.label ?? label}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[8rem]">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => onChange(item.value)}
|
onClick={() => onChange(item.value)}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
|
||||||
{item.value === defaultValue && (
|
{item.value === defaultValue && (
|
||||||
<Check className="ml-auto h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
<span>{item.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,15 @@ export function OrgSelector({
|
|||||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||||
|
|
||||||
const picker = (
|
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
|
<div
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,16 +9,13 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
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 { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -152,49 +149,42 @@ export default function ProfileIcon() {
|
|||||||
>
|
>
|
||||||
<span>{t("changePassword")}</span>
|
<span>{t("changePassword")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
||||||
|
<Smartphone className="mr-2 h-4 w-4" />
|
||||||
<span>{t("viewDevices") || "View Devices"}</span>
|
<span>{t("viewDevices") || "View Devices"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSub>
|
{(["light", "dark", "system"] as const).map(
|
||||||
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
|
(themeOption) => (
|
||||||
{userTheme === "light" && (
|
<DropdownMenuItem
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
key={themeOption}
|
||||||
)}
|
onClick={() => handleThemeChange(themeOption)}
|
||||||
{userTheme === "dark" && (
|
>
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
{themeOption === "light" && (
|
||||||
)}
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
{userTheme === "system" && (
|
)}
|
||||||
<Laptop className="mr-2 h-4 w-4" />
|
{themeOption === "dark" && (
|
||||||
)}
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
<span className="capitalize">{t(userTheme)}</span>
|
)}
|
||||||
</DropdownMenuSubTrigger>
|
{themeOption === "system" && (
|
||||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
<Laptop className="mr-2 h-4 w-4" />
|
||||||
{(["light", "dark", "system"] as const).map(
|
)}
|
||||||
(themeOption) => (
|
<span className="capitalize">
|
||||||
<DropdownMenuItem
|
{t(themeOption)}
|
||||||
key={themeOption}
|
</span>
|
||||||
onClick={() =>
|
{userTheme === themeOption && (
|
||||||
handleThemeChange(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>
|
||||||
className="flex items-center gap-2"
|
</span>
|
||||||
>
|
)}
|
||||||
<span className="capitalize">
|
</DropdownMenuItem>
|
||||||
{t(themeOption)}
|
)
|
||||||
</span>
|
)}
|
||||||
{userTheme === themeOption && (
|
|
||||||
<Check className="ml-auto h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
|
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { Switch } from "@app/components/ui/switch";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
import { build } from "@server/build";
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|||||||
@@ -52,11 +52,9 @@ export default function RegenerateInvitationForm({
|
|||||||
}: RegenerateInvitationFormProps) {
|
}: RegenerateInvitationFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [expiresInDays, setExpiresInDays] = useState(3);
|
const [sendEmail, setSendEmail] = useState(true);
|
||||||
const { env } = useEnvContext();
|
|
||||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
|
||||||
const [validHours, setValidHours] = useState(72);
|
const [validHours, setValidHours] = useState(72);
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient(useEnvContext());
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -73,11 +71,10 @@ export default function RegenerateInvitationForm({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSendEmail(env.email.emailEnabled);
|
setSendEmail(true);
|
||||||
setValidHours(72);
|
setValidHours(72);
|
||||||
setExpiresInDays(3);
|
|
||||||
}
|
}
|
||||||
}, [open, env.email.emailEnabled]);
|
}, [open]);
|
||||||
|
|
||||||
async function handleRegenerate() {
|
async function handleRegenerate() {
|
||||||
if (!invitation) return;
|
if (!invitation) return;
|
||||||
@@ -99,16 +96,15 @@ export default function RegenerateInvitationForm({
|
|||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
roleIds: invitation.roleIds,
|
roleIds: invitation.roleIds,
|
||||||
validHours,
|
validHours,
|
||||||
sendEmail: env.email.emailEnabled && sendEmail,
|
sendEmail,
|
||||||
regenerate: true
|
regenerate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const link = res.data.data.inviteLink;
|
const link = res.data.data.inviteLink;
|
||||||
setInviteLink(link);
|
setInviteLink(link);
|
||||||
setExpiresInDays(validHours / 24);
|
|
||||||
|
|
||||||
if (sendEmail && env.email.emailEnabled) {
|
if (sendEmail) {
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: t("inviteRegenerated"),
|
title: t("inviteRegenerated"),
|
||||||
@@ -131,7 +127,9 @@ export default function RegenerateInvitationForm({
|
|||||||
onRegenerate({
|
onRegenerate({
|
||||||
id: invitation.id,
|
id: invitation.id,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
expiresAt: new Date(res.data.data.expiresAt).toISOString(),
|
expiresAt: new Date(
|
||||||
|
res.data.data.expiresAt
|
||||||
|
).toISOString(),
|
||||||
roleLabels: invitation.roleLabels,
|
roleLabels: invitation.roleLabels,
|
||||||
roleIds: invitation.roleIds
|
roleIds: invitation.roleIds
|
||||||
});
|
});
|
||||||
@@ -176,47 +174,33 @@ export default function RegenerateInvitationForm({
|
|||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>
|
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
|
||||||
{inviteLink
|
|
||||||
? t("inviteRegenerated")
|
|
||||||
: t("inviteRegenerate")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{inviteLink
|
{t("inviteRegenerateDescription")}
|
||||||
? sendEmail && env.email.emailEnabled
|
|
||||||
? t("inviteEmailSentDescription")
|
|
||||||
: t("inviteSentDescription")
|
|
||||||
: t("inviteRegenerateDescription")}
|
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
{!inviteLink ? (
|
{!inviteLink ? (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div className="space-y-2">
|
<p>
|
||||||
<Label>{t("email")}</Label>
|
{t("inviteQuestionRegenerate", {
|
||||||
<p className="text-sm">{invitation?.email}</p>
|
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>
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
{env.email.emailEnabled && (
|
<Label>{t("inviteValidityPeriod")}</Label>
|
||||||
<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>
|
|
||||||
<Select
|
<Select
|
||||||
value={validHours.toString()}
|
value={validHours.toString()}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -225,7 +209,9 @@ export default function RegenerateInvitationForm({
|
|||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={t("selectDuration")}
|
placeholder={t(
|
||||||
|
"inviteValidityPeriodSelect"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -242,13 +228,9 @@ export default function RegenerateInvitationForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 max-w-md">
|
||||||
<p>
|
<p>{t("inviteRegenerateMessage")}</p>
|
||||||
{t("inviteExpiresIn", {
|
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||||
days: expiresInDays
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{inviteLink && <CopyTextBox text={inviteLink} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
@@ -267,7 +249,7 @@ export default function RegenerateInvitationForm({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button>{t("done")}</Button>
|
<Button variant="outline">{t("close")}</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
)}
|
)}
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
|
import { durationToMs } from "@app/lib/durationToMs";
|
||||||
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|||||||
@@ -8,32 +8,27 @@ import {
|
|||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
const MAX_ROLE_BADGES = 3;
|
const MAX_ROLE_BADGES = 3;
|
||||||
|
|
||||||
export default function UserRoleBadges({
|
export default function UserRoleBadges({
|
||||||
roleLabels,
|
roleLabels
|
||||||
isOwner
|
|
||||||
}: {
|
}: {
|
||||||
roleLabels: string[];
|
roleLabels: string[];
|
||||||
isOwner?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
|
||||||
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
|
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
|
||||||
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
|
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{isOwner && (
|
|
||||||
<Badge variant="secondary">{t("accessRoleOwner")}</Badge>
|
|
||||||
)}
|
|
||||||
{visible.map((label, i) => (
|
{visible.map((label, i) => (
|
||||||
<Badge key={`${label}-${i}`} variant="secondary">
|
<Badge key={`${label}-${i}`} variant="secondary">
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{overflow.length > 0 && <OverflowRolesPopover labels={overflow} />}
|
{overflow.length > 0 && (
|
||||||
|
<OverflowRolesPopover labels={overflow} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Crown,
|
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -34,20 +33,12 @@ import z from "zod";
|
|||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton";
|
import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton";
|
||||||
import IdpTypeBadge from "./IdpTypeBadge";
|
import IdpTypeBadge from "./IdpTypeBadge";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "./ui/tooltip";
|
|
||||||
import {
|
import {
|
||||||
ControlledDataTable,
|
ControlledDataTable,
|
||||||
type ExtendedColumnDef
|
type ExtendedColumnDef
|
||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
import UserRoleBadges from "./UserRoleBadges";
|
import UserRoleBadges from "./UserRoleBadges";
|
||||||
|
|
||||||
const OWNER_FILTER_VALUE = "owner";
|
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -104,13 +95,7 @@ export default function UsersTable({
|
|||||||
const roleIdsFromSearchParams = useMemo(() => {
|
const roleIdsFromSearchParams = useMemo(() => {
|
||||||
const sp = new URLSearchParams(searchParams);
|
const sp = new URLSearchParams(searchParams);
|
||||||
return [
|
return [
|
||||||
...new Set(
|
...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id)))
|
||||||
sp
|
|
||||||
.getAll("role_id")
|
|
||||||
.filter(
|
|
||||||
(id) => /^\d+$/.test(id) || id === OWNER_FILTER_VALUE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
}, [searchParams.toString()]);
|
}, [searchParams.toString()]);
|
||||||
|
|
||||||
@@ -141,7 +126,7 @@ export default function UsersTable({
|
|||||||
sp.delete("role_id");
|
sp.delete("role_id");
|
||||||
sp.delete("page");
|
sp.delete("page");
|
||||||
for (const id of values) {
|
for (const id of values) {
|
||||||
if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) {
|
if (/^\d+$/.test(id)) {
|
||||||
sp.append("role_id", id);
|
sp.append("role_id", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,32 +171,7 @@ export default function UsersTable({
|
|||||||
<Icon className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="inline-flex gap-1 items-center">
|
|
||||||
{row.original.displayUsername}{" "}
|
|
||||||
{row.original.id === user.userId && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
·
|
|
||||||
</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",
|
accessorKey: "idpName",
|
||||||
@@ -262,12 +222,7 @@ export default function UsersTable({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
|
||||||
<UserRoleBadges
|
|
||||||
roleLabels={row.original.roleLabels}
|
|
||||||
isOwner={row.original.isOwner}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -155,13 +155,13 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
|
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
|
||||||
|
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
||||||
|
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={t("commandActionModeInfo")}
|
heading={t("commandActionModeInfo")}
|
||||||
className="[&_[cmdk-group-heading]]:text-sm"
|
className="[&_[cmdk-group-heading]]:text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
|
||||||
|
|
||||||
{!isActionMode &&
|
{!isActionMode &&
|
||||||
navigationGroups.map((group, groupIndex) => (
|
navigationGroups.map((group, groupIndex) => (
|
||||||
<React.Fragment key={group.heading}>
|
<React.Fragment key={group.heading}>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
|
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import LoadingDots from "@app/components/LoadingDots";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||||
@@ -82,8 +81,8 @@ export function LauncherFlatResourceList({
|
|||||||
if (resources.length === 0) {
|
if (resources.length === 0) {
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
<LoadingDots size="sm" />
|
<Loader2 className="size-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
LauncherViewConfig
|
LauncherViewConfig
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import LoadingDots from "@app/components/LoadingDots";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||||
@@ -110,8 +109,8 @@ export function LauncherGroupList({
|
|||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
<LoadingDots size="sm" />
|
<Loader2 className="size-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
LAUNCHER_UNLABELED_GROUP_KEY
|
LAUNCHER_UNLABELED_GROUP_KEY
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import LoadingDots from "@app/components/LoadingDots";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -170,8 +169,8 @@ export function LauncherGroupSection({
|
|||||||
|
|
||||||
<CollapsibleContent className="w-full">
|
<CollapsibleContent className="w-full">
|
||||||
{showInitialLoader ? (
|
{showInitialLoader ? (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||||
<LoadingDots size="sm" />
|
<Loader2 className="size-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : resources.length === 0 ? (
|
) : resources.length === 0 ? (
|
||||||
<p className="py-4 text-sm text-muted-foreground">
|
<p className="py-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
|
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 CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
@@ -30,23 +28,7 @@ import {
|
|||||||
} from "@app/components/SidePanel";
|
} from "@app/components/SidePanel";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { Button } from "@app/components/ui/button";
|
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 { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
|
||||||
import type { AiConfigBlock } from "@app/lib/aiClientConfig";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import {
|
import {
|
||||||
derivePublicAuthState,
|
derivePublicAuthState,
|
||||||
formatPublicResourceType
|
formatPublicResourceType
|
||||||
@@ -54,10 +36,7 @@ import {
|
|||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
import { launcherQueries } from "@app/lib/queries";
|
||||||
import type {
|
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||||
LauncherResource,
|
|
||||||
LauncherSiteInfo
|
|
||||||
} from "@server/routers/launcher/types";
|
|
||||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
@@ -65,18 +44,15 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronsUpDown,
|
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type LauncherResourcePanelProps = {
|
type LauncherResourcePanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -84,7 +60,6 @@ type LauncherResourcePanelProps = {
|
|||||||
resource: LauncherResource | null;
|
resource: LauncherResource | null;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onFilterBySite: (site: LauncherSiteInfo) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type LauncherResourceDetailResult =
|
type LauncherResourceDetailResult =
|
||||||
@@ -178,249 +153,6 @@ function HealthStatusDisplay({
|
|||||||
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
|
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||||
const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"];
|
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 }) {
|
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -503,16 +235,12 @@ function PublicResourceDetails({
|
|||||||
orgId,
|
orgId,
|
||||||
launcherResource,
|
launcherResource,
|
||||||
resource,
|
resource,
|
||||||
authInfo,
|
authInfo
|
||||||
isAdmin,
|
|
||||||
onFilterBySite
|
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
launcherResource: LauncherResource;
|
launcherResource: LauncherResource;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
authInfo: GetResourceAuthInfoResponse;
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
isAdmin: boolean;
|
|
||||||
onFilterBySite: (site: LauncherSiteInfo) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const mode = resource.mode || "";
|
const mode = resource.mode || "";
|
||||||
@@ -600,12 +328,6 @@ function PublicResourceDetails({
|
|||||||
</InfoSections>
|
</InfoSections>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<LauncherResourceSitesSection
|
|
||||||
orgId={orgId}
|
|
||||||
sites={launcherResource.sites ?? []}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onFilterBySite={onFilterBySite}
|
|
||||||
/>
|
|
||||||
{showAuthMethods ? (
|
{showAuthMethods ? (
|
||||||
<PublicResourceAuthMethods authInfo={authInfo} />
|
<PublicResourceAuthMethods authInfo={authInfo} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -641,19 +363,14 @@ function PublicResourceDetails({
|
|||||||
function PrivateResourceDetails({
|
function PrivateResourceDetails({
|
||||||
orgId,
|
orgId,
|
||||||
launcherResource,
|
launcherResource,
|
||||||
resource,
|
resource
|
||||||
isAdmin,
|
|
||||||
onFilterBySite
|
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
launcherResource: LauncherResource;
|
launcherResource: LauncherResource;
|
||||||
resource: GetSiteResourceResponse;
|
resource: GetSiteResourceResponse;
|
||||||
isAdmin: boolean;
|
|
||||||
onFilterBySite: (site: LauncherSiteInfo) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const isInference = resource.mode === "inference";
|
const isInference = resource.mode === "inference";
|
||||||
const isSsh = resource.mode === "ssh";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -700,19 +417,6 @@ function PrivateResourceDetails({
|
|||||||
/>
|
/>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<LauncherResourceSitesSection
|
|
||||||
orgId={orgId}
|
|
||||||
sites={launcherResource.sites ?? []}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onFilterBySite={onFilterBySite}
|
|
||||||
/>
|
|
||||||
{isSsh ? (
|
|
||||||
<LauncherPrivateSshCommandSection
|
|
||||||
alias={resource.alias}
|
|
||||||
niceId={resource.niceId}
|
|
||||||
pamMode={resource.pamMode}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{isInference ? (
|
{isInference ? (
|
||||||
<>
|
<>
|
||||||
<LauncherInferenceModelsSection
|
<LauncherInferenceModelsSection
|
||||||
@@ -736,15 +440,11 @@ function PrivateResourceDetails({
|
|||||||
function LauncherResourcePanelBody({
|
function LauncherResourcePanelBody({
|
||||||
orgId,
|
orgId,
|
||||||
resource,
|
resource,
|
||||||
open,
|
open
|
||||||
isAdmin,
|
|
||||||
onFilterBySite
|
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resource: LauncherResource;
|
resource: LauncherResource;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
isAdmin: boolean;
|
|
||||||
onFilterBySite: (site: LauncherSiteInfo) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { data, isPending, isError } = useQuery({
|
const { data, isPending, isError } = useQuery({
|
||||||
@@ -777,8 +477,6 @@ function LauncherResourcePanelBody({
|
|||||||
launcherResource={resource}
|
launcherResource={resource}
|
||||||
resource={detail.data}
|
resource={detail.data}
|
||||||
authInfo={detail.authInfo}
|
authInfo={detail.authInfo}
|
||||||
isAdmin={isAdmin}
|
|
||||||
onFilterBySite={onFilterBySite}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -788,8 +486,6 @@ function LauncherResourcePanelBody({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
launcherResource={resource}
|
launcherResource={resource}
|
||||||
resource={detail.data}
|
resource={detail.data}
|
||||||
isAdmin={isAdmin}
|
|
||||||
onFilterBySite={onFilterBySite}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -799,8 +495,7 @@ export function LauncherResourcePanel({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
resource,
|
resource,
|
||||||
orgId,
|
orgId,
|
||||||
isAdmin,
|
isAdmin
|
||||||
onFilterBySite
|
|
||||||
}: LauncherResourcePanelProps) {
|
}: LauncherResourcePanelProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -816,8 +511,6 @@ export function LauncherResourcePanel({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
open={open}
|
open={open}
|
||||||
isAdmin={isAdmin}
|
|
||||||
onFilterBySite={onFilterBySite}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidePanelBody>
|
</SidePanelBody>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
type LauncherGroup,
|
type LauncherGroup,
|
||||||
type LauncherResource,
|
type LauncherResource,
|
||||||
type LauncherScaleInfo,
|
type LauncherScaleInfo,
|
||||||
type LauncherSiteInfo,
|
|
||||||
type LauncherViewConfig,
|
type LauncherViewConfig,
|
||||||
type LauncherViewRecord,
|
type LauncherViewRecord,
|
||||||
type ListLauncherResourcesResponse
|
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);
|
const hasHandledAutoOpen = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -804,7 +795,6 @@ export default function ResourceLauncher({
|
|||||||
resource={selectedResource}
|
resource={selectedResource}
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onFilterBySite={handleFilterBySite}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeSavedView ? (
|
{activeSavedView ? (
|
||||||
|
|||||||
@@ -961,6 +961,7 @@ export function PolicyAccessRulesTable({
|
|||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
message={t("rulesNoOne")}
|
message={t("rulesNoOne")}
|
||||||
action={emptyStateAction}
|
action={emptyStateAction}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -81,15 +81,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
<span className="absolute inset-0 flex items-center justify-center">
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span
|
<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" }}
|
style={{ animationDelay: "0ms" }}
|
||||||
/>
|
/>
|
||||||
<span
|
<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" }}
|
style={{ animationDelay: "200ms" }}
|
||||||
/>
|
/>
|
||||||
<span
|
<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" }}
|
style={{ animationDelay: "400ms" }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,46 +5,52 @@ import { useTranslations } from "next-intl";
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
const PLACEHOLDER_ROW_COUNT = 5;
|
const PLACEHOLDER_ROW_COUNT = 5;
|
||||||
|
const COMPACT_PLACEHOLDER_ROW_COUNT = 2;
|
||||||
|
|
||||||
type DataTableEmptyStateProps = {
|
type DataTableEmptyStateProps = {
|
||||||
colSpan: number;
|
colSpan: number;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataTableEmptyState({
|
export function DataTableEmptyState({
|
||||||
colSpan,
|
colSpan,
|
||||||
action,
|
action,
|
||||||
message
|
message,
|
||||||
|
compact = false
|
||||||
}: DataTableEmptyStateProps) {
|
}: DataTableEmptyStateProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const placeholderRows = compact
|
||||||
|
? COMPACT_PLACEHOLDER_ROW_COUNT
|
||||||
|
: PLACEHOLDER_ROW_COUNT;
|
||||||
|
const minHeightClass = compact ? "min-h-[4.5rem]" : "min-h-[11rem]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
|
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
|
||||||
<TableCell colSpan={colSpan} className="p-0">
|
<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
|
<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
|
aria-hidden
|
||||||
>
|
>
|
||||||
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
|
{Array.from({ length: placeholderRows }).map((_, i) => (
|
||||||
(_, i) => (
|
<div key={i} className="h-10 shrink-0" />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{message ?? t("noResults")}
|
{message ?? t("noResults")}
|
||||||
</p>
|
</p>
|
||||||
{action ? (
|
{action}
|
||||||
<div className="bg-background">{action}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": [
|
"@server/*": [
|
||||||
|
|||||||
Reference in New Issue
Block a user