Compare commits

...

29 Commits

Author SHA1 Message Date
miloschwartz 92ebbc9ac7 show ssh commands 2026-09-11 15:23:01 -04:00
miloschwartz 110b6d19a5 add sites to resource launcher panel 2026-09-11 14:57:36 -04:00
miloschwartz d776478350 add better page loading indicator 2026-09-11 14:09:56 -04:00
miloschwartz 8db205352f more cosmetic improvememnts 2026-09-11 12:17:29 -04:00
miloschwartz fe8aae7839 move improvements to user management ui 2026-09-11 11:58:51 -04:00
miloschwartz 33c1eb1426 improvements to create user 2026-09-11 11:39:33 -04:00
miloschwartz e73ea120e3 add server side filter for server admin 2026-09-11 11:29:09 -04:00
Milo Schwartz 0b11bdc9ff Merge pull request #3701 from fosrl/feat/multiple-server-admins
feat: allow multiple server admins
2026-09-11 10:57:33 -04:00
miloschwartz 7a645adbc4 visual adjustments to logo empty state and profile dropdown 2026-09-11 10:37:54 -04:00
miloschwartz 5157bea2b7 cosmetic adjustments 2026-09-10 17:38:27 -04:00
Milo Schwartz 58a9417e51 Merge pull request #3657 from fosrl/feat/master-table-of-orgs
feat: Master table of orgs in server admin
2026-09-10 17:18:50 -04:00
miloschwartz c7926223dc switch to lru in memory cache and dont cache failed sessions 2026-09-10 16:48:44 -04:00
miloschwartz 80d08d4f8e update screenshots 2026-09-09 10:45:16 -04:00
Owen 82c5dcf16f Fix tsconfig to use react-jsx 2026-09-08 16:45:56 -04:00
Owen 59f0c90836 Fix circular import 2026-09-08 16:42:22 -04:00
Owen b0e64a5e5a Widen subnet 2026-09-08 16:31:58 -04:00
Owen 733d3ece0e Quiet up error logs 2026-09-08 10:01:59 -04:00
Owen 59b228ce39 Quiet log message 2026-09-08 09:26:57 -04:00
Owen 080bcbaf97 Use endpoint instead of reachableAt for remote nodes 2026-09-07 11:55:45 -04:00
Fred KISSIE 0be7628a00 ♻️ refactor 2026-09-04 23:11:29 +02:00
Fred KISSIE 78cddc7775 💬 update text 2026-09-04 20:40:16 +02:00
Fred KISSIE 1ad613f403 🚧 toggle server admin 2026-09-03 22:40:36 +02:00
Fred KISSIE d2936cd10b 🚧 server admin 2026-09-02 19:19:03 +02:00
Fred KISSIE 9853122a51 delete org 2026-09-02 00:25:49 +02:00
Fred KISSIE c411a1a5b9 🚧 wip 2026-08-28 22:49:51 +02:00
Fred KISSIE a4d9365563 ♻️ Only show the username instead of the name+username 2026-08-28 22:09:01 +02:00
Fred KISSIE e9f7678b90 List of orgs, with all columns 2026-08-28 22:00:06 +02:00
Fred KISSIE a904c915d8 List orgs in server 2026-08-28 21:42:20 +02:00
Fred KISSIE cb84c2954b 🚧 WIP 2026-08-27 00:03:56 +02:00
64 changed files with 2840 additions and 1230 deletions
+42 -14
View File
@@ -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 (Newt) 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 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 through a web browser", "proxyResourceDescription": "Create and manage resources that are publicly accessible via a proxy",
"publicResourcesBannerTitle": "Web-based Public Access", "publicResourcesBannerTitle": "Web-based Public 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.", "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.",
"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,6 +465,8 @@
"apiKeysDelete": "Delete API Key", "apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys", "apiKeysManage": "Manage API Keys",
"apiKeysDescription": "API keys are used to authenticate with the integration API", "apiKeysDescription": "API keys are used to authenticate with the integration API",
"orgsManage": "Manage Organizations",
"orgsDescription": "View and manage all organizations on this instance",
"provisioningKeysTitle": "Provisioning Key", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
@@ -520,12 +522,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 All Users", "userTitle": "Manage Users",
"userDescription": "View and manage all users in the system", "userDescription": "View and manage all users in this instance",
"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 server users...", "userSearch": "Search 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",
@@ -594,7 +596,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": "Revoke previous invitation and create a new one", "inviteRegenerateDescription": "Create a new invite link for this user. The previous invitation will be revoked.",
"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.",
@@ -674,11 +676,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": "Determine how you want to create the user", "userTypeDescription": "Select the identity provider to use for this user",
"userSettings": "User Information", "userSettings": "User Information",
"userSettingsDescription": "Enter the details for the new user", "userSettingsDescription": "Enter the general details for the new user",
"inviteEmailSent": "Send invite email to user", "inviteEmailSent": "Send invite email to user",
"inviteValid": "Invite Valid For (days)", "inviteValid": "Invite Valid For",
"selectDuration": "Select duration", "selectDuration": "Select duration",
"selectResource": "Select Resource", "selectResource": "Select Resource",
"filterByResource": "Filter By Resource", "filterByResource": "Filter By Resource",
@@ -716,6 +718,7 @@
"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",
@@ -1214,7 +1217,7 @@
"orgPoliciesEdit": "Edit Organization Policy", "orgPoliciesEdit": "Edit Organization Policy",
"org": "Organization", "org": "Organization",
"orgSelect": "Select organization", "orgSelect": "Select organization",
"orgSearch": "Search org", "orgSearch": "Search organizations...",
"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)",
@@ -1353,7 +1356,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}}",
@@ -1424,6 +1427,24 @@
"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",
@@ -2095,9 +2116,10 @@
"resourceBudgetSettings": "Budget", "resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
"sidebarAllUsers": "All Users", "sidebarAllUsers": "Users",
"sidebarIdentityProviders": "Identity Providers", "sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients", "sidebarClients": "Clients",
@@ -3974,11 +3996,12 @@
"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": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStatePreviewDescription": "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.",
@@ -4253,6 +4276,11 @@
"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",
+5
View File
@@ -34,6 +34,11 @@ 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
} }
]; ];
} }
+1 -1
View File
@@ -96,6 +96,7 @@
"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",
@@ -103,7 +104,6 @@
"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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 802 KiB

+2 -8
View File
@@ -1,13 +1,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger"; import logger from "@server/logger";
import { createLocalCache } from "@server/lib/createLocalCache";
// Create local cache with maxKeys limit to prevent memory leaks export const localCache = createLocalCache();
// 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(() => {
+79
View File
@@ -0,0 +1,79 @@
import { LRUCache } from "lru-cache";
const DEFAULT_MAX_KEYS = 10000;
const DEFAULT_TTL_MS = 3600 * 1000;
export type LocalCache = {
get<T>(key: string): T | undefined;
set(key: string, value: unknown, ttlSeconds?: number): boolean;
del(key: string | string[]): number;
has(key: string): boolean;
keys(): string[];
flushAll(): void;
getStats(): { keys: number };
getTtl(key: string): number | undefined;
};
export function createLocalCache(max = DEFAULT_MAX_KEYS): LocalCache {
const lru = new LRUCache<string, {}>({
max,
ttl: DEFAULT_TTL_MS,
updateAgeOnGet: false
});
return {
get<T>(key: string): T | undefined {
return lru.get(key) as T | undefined;
},
set(key: string, value: unknown, ttlSeconds?: number): boolean {
const stored = value as {};
if (ttlSeconds === undefined) {
lru.set(key, stored);
} else if (ttlSeconds === 0) {
lru.set(key, stored, { ttl: 0 });
} else {
lru.set(key, stored, { ttl: ttlSeconds * 1000 });
}
return true;
},
del(key: string | string[]): number {
const keys = Array.isArray(key) ? key : [key];
let deleted = 0;
for (const k of keys) {
if (lru.delete(k)) {
deleted++;
}
}
return deleted;
},
has(key: string): boolean {
return lru.has(key);
},
keys(): string[] {
return [...lru.keys()];
},
flushAll(): void {
lru.clear();
},
getStats(): { keys: number } {
return { keys: lru.size };
},
getTtl(key: string): number | undefined {
if (!lru.has(key)) {
return undefined;
}
const remaining = lru.getRemainingTTL(key);
if (!Number.isFinite(remaining)) {
return 0;
}
return Date.now() + remaining;
}
};
}
+1 -1
View File
@@ -71,7 +71,7 @@ export async function withRetry<T>(
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, `Transient DB issue 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));
+2 -2
View File
@@ -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/20"), subnet_group: z.string().optional().default("100.89.137.0/18"),
block_size: z.number().positive().gt(0).optional().default(24), block_size: z.number().positive().gt(0).optional().default(22),
site_block_size: z site_block_size: z
.number() .number()
.positive() .positive()
+4 -14
View File
@@ -11,17 +11,11 @@
* 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";
// Create local cache with maxKeys limit to prevent memory leaks export const localCache = createLocalCache();
// 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(() => {
@@ -301,15 +295,11 @@ 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 NodeCache when the regional Redis is unavailable. * Falls back to a local LRU cache 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 = new NodeCache({ const regionalLocalCache = createLocalCache();
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache { class RegionalAdaptiveCache {
private useRedis(): boolean { private useRedis(): boolean {
+6 -6
View File
@@ -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 NodeCache from "node-cache"; import { createLocalCache } from "@server/lib/createLocalCache";
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 = new NodeCache(); private statusCache = createLocalCache();
private licenseKeyCache = new NodeCache(); private licenseKeyCache = createLocalCache();
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); this.statusCache.set(this.statusKey, status, 0);
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<LicenseKeyCache>(key, value); this.licenseKeyCache.set(key, value, 0);
} }
} 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); this.statusCache.set(this.statusKey, status, 0);
return status; return status;
} }
+5
View File
@@ -2478,7 +2478,12 @@ hybridRouter.post(
destinations: destinations destinations: destinations
}); });
} catch (error) { } catch (error) {
if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error); logger.error(error);
}
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -0,0 +1,23 @@
/*
* 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,11 +12,27 @@
*/ */
import axios from "axios"; import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db"; import { db, 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 "#private/routers/ws"; import { sendToClient } from "../ws";
import {
exitNodeEvents,
EXIT_NODE_ONLINE_EVENT,
ExitNodeOnlineEvent
} from "./exitNodeEvents";
exitNodeEvents.on(
EXIT_NODE_ONLINE_EVENT,
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
logger.error("Failed to schedule exit node reconnect", {
error
});
});
}
);
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
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect { interface PendingReconnect {
startTime: number; startTime: number;
reachableAt: string; endpoint: string;
} }
// In-memory tracking for this node // In-memory tracking for this node
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
*/ */
export async function scheduleExitNodeReconnect( export async function scheduleExitNodeReconnect(
exitNodeId: number, exitNodeId: number,
reachableAt: string endpoint: string
): Promise<void> { ): Promise<void> {
logger.info( logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})` `Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
); );
const entry: PendingReconnect = { const entry: PendingReconnect = {
startTime: Date.now(), startTime: Date.now(),
reachableAt endpoint
}; };
pendingReconnects.set(exitNodeId, entry); pendingReconnects.set(exitNodeId, entry);
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
); );
await redisManager.hset( await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`, `${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt", "endpoint",
reachableAt endpoint
); );
} }
} }
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
`${REDIS_HASH_PREFIX}${id}`, `${REDIS_HASH_PREFIX}${id}`,
"startTime" "startTime"
); );
const reachableAt = await redisManager.hget( const endpoint = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`, `${REDIS_HASH_PREFIX}${id}`,
"reachableAt" "endpoint"
); );
if (startTimeStr && reachableAt) { if (startTimeStr && endpoint) {
toProcess.set(id, { toProcess.set(id, {
startTime: parseInt(startTimeStr, 10), startTime: parseInt(startTimeStr, 10),
reachableAt endpoint
}); });
} }
} }
@@ -135,7 +151,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 = `${entry.reachableAt}/ping`; const pingUrl = `http://${entry.endpoint}/ping`;
try { try {
await axios.get(pingUrl, { timeout: 5000 }); await axios.get(pingUrl, { timeout: 5000 });
} catch { } catch {
@@ -150,47 +166,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 { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler"; import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
/** /**
* 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, reachableAt: exitNodes.reachableAt }) .select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
.from(exitNodes) .from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1); .limit(1);
@@ -55,12 +55,14 @@ 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 (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) { if (
scheduleExitNodeReconnect( currentExitNode &&
remoteExitNode.exitNodeId, !currentExitNode.online &&
currentExitNode.reachableAt currentExitNode.endpoint
).catch((error) => { ) {
logger.error("Failed to schedule exit node reconnect", { error }); exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
exitNodeId: remoteExitNode.exitNodeId,
endpoint: currentExitNode.endpoint
}); });
} }
} catch (error) { } catch (error) {
+4
View File
@@ -691,8 +691,10 @@ 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) {
logger.debug( logger.debug(
@@ -1121,8 +1123,10 @@ 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 (
resourceSession && resourceSession &&
+12
View File
@@ -87,6 +87,12 @@ authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
authenticated.delete(
"/admin/org/:orgId",
verifyUserIsServerAdmin,
org.adminDeleteOrg
);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get( authenticated.get(
@@ -1378,6 +1384,12 @@ 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,
+147 -51
View File
@@ -522,6 +522,7 @@ 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;
@@ -556,6 +557,7 @@ 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)
@@ -576,7 +578,13 @@ async function listSiteGroups(
const publicRows = await publicQuery const publicRows = await publicQuery
.where(and(...publicConditions)) .where(and(...publicConditions))
.groupBy(sites.siteId, sites.name, sites.type, sites.online); .groupBy(
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);
@@ -586,6 +594,7 @@ 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)
@@ -612,6 +621,7 @@ 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)
@@ -638,7 +648,13 @@ async function listSiteGroups(
const siteRows = await siteResourceQuery const siteRows = await siteResourceQuery
.where(and(...siteConditions)) .where(and(...siteConditions))
.groupBy(sites.siteId, sites.name, sites.type, sites.online); .groupBy(
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);
@@ -648,6 +664,7 @@ 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)
@@ -1061,6 +1078,43 @@ 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[],
@@ -1084,6 +1138,7 @@ 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
@@ -1097,23 +1152,18 @@ 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 seen = new Set<string>(); const byKey = new Map<string, LauncherResource>();
const result: LauncherResource[] = []; const siteIdsByKey = new Map<string, Set<number>>();
for (const row of rows) { for (const row of rows) {
const key = `public:${row.resourceId}`; const key = `public:${row.resourceId}`;
if (seen.has(key)) { let item = byKey.get(key);
continue;
}
seen.add(key);
if (!item) {
const access = formatPublicResourceAccess({ const access = formatPublicResourceAccess({
mode: row.mode, mode: row.mode,
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
@@ -1123,7 +1173,7 @@ async function mapPublicResources(
exitNodeEndpoint: row.exitNodeEndpoint exitNodeEndpoint: row.exitNodeEndpoint
}); });
result.push({ item = {
launcherResourceKey: key, launcherResourceKey: key,
resourceType: "public", resourceType: "public",
resourceId: row.resourceId, resourceId: row.resourceId,
@@ -1134,19 +1184,33 @@ async function mapPublicResources(
enabled: row.enabled, enabled: row.enabled,
mode: row.mode, mode: row.mode,
labels: labelMaps.byResourceId.get(row.resourceId) ?? [], labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
site: sites: []
row.siteId != null };
? { byKey.set(key, item);
siteId: row.siteId, siteIdsByKey.set(key, new Set());
name: row.siteName!,
type: row.siteType!,
online: row.siteOnline ?? undefined
}
: undefined
});
} }
return result; const site = toLauncherSiteInfo(row);
if (!site) {
continue;
}
const seenSiteIds = siteIdsByKey.get(key)!;
if (seenSiteIds.has(site.siteId)) {
continue;
}
seenSiteIds.add(site.siteId);
item.sites.push(site);
}
for (const item of byKey.values()) {
item.sites.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
item.site = pickPrimarySite(item.sites, siteIdFilter);
}
return Array.from(byKey.values());
} }
async function mapSiteResources( async function mapSiteResources(
@@ -1175,6 +1239,7 @@ 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
}) })
@@ -1189,23 +1254,18 @@ 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 seen = new Set<string>(); const byKey = new Map<string, LauncherResource>();
const result: LauncherResource[] = []; const siteIdsByKey = new Map<string, Set<number>>();
for (const row of rows) { for (const row of rows) {
const key = `site:${row.siteResourceId}`; const key = `site:${row.siteResourceId}`;
if (seen.has(key)) { let item = byKey.get(key);
continue;
}
seen.add(key);
if (!item) {
const access = formatSiteResourceAccess({ const access = formatSiteResourceAccess({
mode: row.mode, mode: row.mode,
destination: row.destination, destination: row.destination,
@@ -1217,7 +1277,7 @@ async function mapSiteResources(
aliasAddress: row.aliasAddress aliasAddress: row.aliasAddress
}); });
result.push({ item = {
launcherResourceKey: key, launcherResourceKey: key,
resourceType: "site", resourceType: "site",
resourceId: row.siteResourceId, resourceId: row.siteResourceId,
@@ -1228,20 +1288,35 @@ async function mapSiteResources(
iconUrl: null, iconUrl: null,
enabled: row.enabled, enabled: row.enabled,
mode: row.mode, mode: row.mode,
labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], labels:
site: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
row.siteId != null sites: []
? { };
siteId: row.siteId, byKey.set(key, item);
name: row.siteName!, siteIdsByKey.set(key, new Set());
type: row.siteType!,
online: row.siteOnline ?? undefined
}
: undefined
});
} }
return result; const site = toLauncherSiteInfo(row);
if (!site) {
continue;
}
const seenSiteIds = siteIdsByKey.get(key)!;
if (seenSiteIds.has(site.siteId)) {
continue;
}
seenSiteIds.add(site.siteId);
item.sites.push(site);
}
for (const item of byKey.values()) {
item.sites.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
item.site = pickPrimarySite(item.sites, siteIdFilter);
}
return Array.from(byKey.values());
} }
function filterResourcesBySite( function filterResourcesBySite(
@@ -1252,13 +1327,17 @@ 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((item) => !item.site && item.mode !== "inference"); return items.filter(
(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) => item.site?.siteId === siteId); return items.filter((item) =>
item.sites.some((site) => site.siteId === siteId)
);
} }
function filterResourcesByLabel( function filterResourcesByLabel(
@@ -1499,6 +1578,7 @@ 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)
@@ -1507,7 +1587,13 @@ 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(sites.siteId, sites.name, sites.type, sites.online); .groupBy(
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);
@@ -1517,6 +1603,7 @@ 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)
@@ -1540,6 +1627,7 @@ 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)
@@ -1551,7 +1639,13 @@ async function collectAccessibleSites(
) )
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(and(...siteConditions)) .where(and(...siteConditions))
.groupBy(sites.siteId, sites.name, sites.type, sites.online); .groupBy(
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);
@@ -1561,6 +1655,7 @@ 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)
@@ -1675,6 +1770,7 @@ 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
})) }))
+3 -2
View File
@@ -32,6 +32,7 @@ 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;
}; };
@@ -51,6 +52,7 @@ export type LauncherResource = {
mode: string; mode: string;
labels: LauncherLabel[]; labels: LauncherLabel[];
site?: LauncherSiteInfo; site?: LauncherSiteInfo;
sites: LauncherSiteInfo[];
}; };
export type LauncherGroup = { export type LauncherGroup = {
@@ -184,8 +186,7 @@ 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: "default" } | { type: "saved"; viewId: number };
| { 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.error( logger.warn(
`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.error( logger.warn(
`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.error( logger.warn(
`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.error( logger.warn(
`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.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
); );
await sendCancel(); await sendCancel();
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (sitesToProcess.length === 0) { if (sitesToProcess.length === 0) {
logger.error( logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel(); await sendCancel();
return; return;
} }
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
); );
continue; continue;
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, site.exitNodeId)); .where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) { if (!exitNode) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
); );
continue; continue;
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (!handshakeInitiated) { if (!handshakeInitiated) {
logger.error( logger.warn(
`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();
+98
View File
@@ -0,0 +1,98 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
const adminDeleteOrgSchema = z.strictObject({
orgId: z.string()
});
export type AdminDeleteOrgResponse = {};
registry.registerPath({
method: "delete",
path: "/admin/org/{orgId}",
description: "Delete any organization in the system (server admin).",
tags: [OpenAPITags.Org],
request: {
params: adminDeleteOrgSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function adminDeleteOrg(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
const result = await deleteOrgById(orgId);
sendTerminationMessages(result);
return response(res, {
data: null,
success: true,
error: false,
message: "Organization deleted successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
+241
View File
@@ -0,0 +1,241 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { orgs, resources, sites, userOrgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import type { PaginatedResponse } from "@server/types/Pagination";
const adminListOrgsSchema = z.strictObject({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name", "createdAt"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name", "createdAt"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
export type AdminOrgRow = {
orgId: string;
name: string;
subnet: string | null;
utilitySubnet: string | null;
createdAt: string | null;
userCount: number;
siteCount: number;
resourceCount: number;
owner: {
userId: string;
username: string;
} | null;
};
export type AdminListOrgsResponse = PaginatedResponse<{
orgs: AdminOrgRow[];
}>;
const AdminListOrgsResponseDataSchema = z.object({
orgs: z.array(
z.object({
orgId: z.string(),
name: z.string(),
subnet: z.string().nullable(),
createdAt: z.string().nullable(),
userCount: z.number(),
siteCount: z.number(),
resourceCount: z.number()
})
),
pagination: z.object({
total: z.number(),
page: z.number(),
pageSize: z.number()
})
});
registry.registerPath({
method: "get",
path: "/admin/orgs",
description:
"List all organizations in the system with usage counts (server admin).",
tags: [OpenAPITags.Org],
request: {
query: adminListOrgsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminListOrgsResponseDataSchema
)
}
}
}
}
});
export async function adminListOrgs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
let conditions: (SQL<unknown> | undefined)[] = [];
if (query) {
const q = "%" + query.toLowerCase() + "%";
conditions.push(
or(
like(sql`LOWER(${orgs.name})`, q),
like(sql`LOWER(${orgs.orgId})`, q),
like(sql`LOWER(${orgs.subnet})`, q)
)
);
}
const sortColumns = {
name: orgs.name,
createdAt: orgs.createdAt
} as const;
const orderBy = sort_by
? order === "asc"
? asc(sortColumns[sort_by])
: desc(sortColumns[sort_by])
: asc(orgs.name);
// Drizzle renders bare column references in the select list without their
// table prefix, which would make a correlated subquery compare a column to
// itself, so the outer `orgs` side is qualified explicitly.
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
const [countRows, rows] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(orgs)
.where(and(...conditions)),
db
.selectDistinct({
orgId: orgs.orgId,
name: orgs.name,
subnet: orgs.subnet,
utilitySubnet: orgs.utilitySubnet,
createdAt: orgs.createdAt,
userCount: sql<number>`(
SELECT COUNT(*)
FROM ${userOrgs}
WHERE ${userOrgs.orgId} = ${orgIdRef}
)`.as("userCount"),
siteCount: sql<number>`(
SELECT COUNT(*)
FROM ${sites}
WHERE ${sites.orgId} = ${orgIdRef}
)`.as("siteCount"),
resourceCount: sql<number>`(
SELECT COUNT(*)
FROM ${resources}
WHERE ${resources.orgId} = ${orgIdRef}
)`.as("resourceCount"),
owner: {
userId: users.userId,
username: users.username
}
})
.from(orgs)
.where(and(...conditions, eq(userOrgs.isOwner, true)))
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(orderBy)
]);
const totalCount = Number(countRows[0]?.count ?? 0);
return response<AdminListOrgsResponse>(res, {
data: {
orgs: rows.map((row) => ({
...row,
userCount: Number(row.userCount ?? 0),
siteCount: Number(row.siteCount ?? 0),
resourceCount: Number(row.resourceCount ?? 0)
})),
pagination: {
total: totalCount,
page,
pageSize
}
},
success: true,
error: false,
message: "Organizations retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}
+2
View File
@@ -9,3 +9,5 @@ export * from "./listOrgs";
export * from "./pickOrgDefaults"; export * from "./pickOrgDefaults";
export * from "./checkOrgUserAccess"; export * from "./checkOrgUserAccess";
export * from "./resetOrgBandwidth"; export * from "./resetOrgBandwidth";
export * from "./adminListOrgs";
export * from "./adminDeleteOrg";
+17 -3
View File
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql, type 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,6 +88,15 @@ 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"
}) })
}); });
@@ -177,7 +186,8 @@ 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") {
@@ -196,7 +206,7 @@ export async function adminListUsers(
} }
} }
const conditions = [eq(users.serverAdmin, false)]; const conditions: Array<SQL<unknown> | undefined> = [];
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
@@ -233,6 +243,10 @@ 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(
+151
View File
@@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const setServerAdminParamsSchema = z.strictObject({
userId: z.string()
});
const setServerAdminBodySchema = z.strictObject({
serverAdmin: z.boolean()
});
export type AdminSetServerAdminResponse = {
userId: string;
serverAdmin: boolean;
};
const AdminSetServerAdminResponseDataSchema = z.object({
userId: z.string(),
serverAdmin: z.boolean()
});
registry.registerPath({
method: "post",
path: "/user/{userId}/server-admin",
description:
"Promote or demote a user's server admin status (server admin).",
tags: [OpenAPITags.User],
request: {
params: setServerAdminParamsSchema,
body: {
content: {
"application/json": {
schema: setServerAdminBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminSetServerAdminResponseDataSchema
)
}
}
}
}
});
export async function adminSetServerAdmin(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setServerAdminParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId } = parsedParams.data;
const { serverAdmin } = parsedBody.data;
const [existingUser] = await db
.select({
userId: users.userId,
serverAdmin: users.serverAdmin,
type: users.type
})
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!existingUser) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (existingUser.type !== "internal") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin status can only be changed for internal users"
)
);
}
if (!serverAdmin && req.user?.userId === userId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You cannot remove your own server admin status"
)
);
}
if (existingUser.serverAdmin !== serverAdmin) {
logger.info(
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
);
await db
.update(users)
.set({ serverAdmin })
.where(eq(users.userId, userId));
}
return response<AdminSetServerAdminResponse>(res, {
data: {
userId: existingUser.userId,
serverAdmin
},
success: true,
error: false,
message: serverAdmin
? "User promoted to server admin successfully"
: "User demoted from server admin successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+1
View File
@@ -11,6 +11,7 @@ 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";
+33 -6
View File
@@ -95,22 +95,34 @@ 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((val) => { .preprocess(
(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 includeOwner = raw.some((v) => v === "owner");
const nums = raw const nums = raw
.map((v) => .map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v) typeof v === "string" ? parseInt(v, 10) : Number(v)
) )
.filter((n) => Number.isInteger(n) && n > 0); .filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)]; const unique = [...new Set(nums)];
return unique.length ? unique : undefined; if (!unique.length && !includeOwner) {
}, z.array(z.number().int().positive()).optional()) 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 (repeat query param)" 'Filter users who have any of these role ids in the organization, or "owner" for organization owners (repeat query param)'
}) })
}); });
@@ -193,7 +205,8 @@ 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 ?? []; const roleIds = role_id?.roleIds ?? [];
const includeOwner = role_id?.includeOwner ?? false;
const parsedParams = listUsersParamsSchema.safeParse(req.params); const parsedParams = listUsersParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -267,8 +280,15 @@ export async function listUsers(
conditions.push(eq(users.idpId, idp_id)); conditions.push(eq(users.idpId, idp_id));
} }
if (roleIds.length > 0 || includeOwner) {
const roleFilterParts = [];
if (includeOwner) {
roleFilterParts.push(eq(userOrgs.isOwner, true));
}
if (roleIds.length > 0) { if (roleIds.length > 0) {
conditions.push( roleFilterParts.push(
exists( exists(
db db
.select() .select()
@@ -284,6 +304,13 @@ export async function listUsers(
); );
} }
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(
queryUsersBase() queryUsersBase()
.where(and(...conditions)) .where(and(...conditions))
+1 -1
View File
@@ -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);
} }
} }
+2 -6
View File
@@ -1,9 +1,5 @@
import { Loader2 } from "lucide-react"; import OrgRouteLoading from "@app/components/OrgRouteLoading";
export default function OrgPageLoading() { export default function OrgPageLoading() {
return ( return <OrgRouteLoading />;
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
} }
@@ -38,7 +38,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
export default function AccessControlsPage() { export default function GeneralPage() {
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 AccessControlsPage() {
(build === "enterprise" && !isPaid) || (build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid)); (build === "oss" && !isPaid));
const accessControlsFormSchema = z.object({ const generalFormSchema = 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 AccessControlsPage() {
}); });
const form = useForm({ const form = useForm({
resolver: zodResolver(accessControlsFormSchema), resolver: zodResolver(generalFormSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,
autoProvisioned: user.autoProvisioned || false, autoProvisioned: user.autoProvisioned || false,
@@ -155,7 +155,7 @@ export default function AccessControlsPage() {
} }
} }
async function handleAccessControlsSubmit(e: React.FormEvent) { async function handleGeneralSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const isValid = await form.trigger(); const isValid = await form.trigger();
@@ -196,11 +196,9 @@ export default function AccessControlsPage() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
{t("accessControls")}
</SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("accessControlsDescription")} {t("userGeneralSettingsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@@ -208,11 +206,9 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => onSubmit={(e) => void handleGeneralSubmit(e)}
void handleAccessControlsSubmit(e)
}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="user-general-form"
> >
{user.type !== UserType.Internal && {user.type !== UserType.Internal &&
user.idpType && ( user.idpType && (
@@ -281,9 +277,9 @@ export default function AccessControlsPage() {
type="submit" type="submit"
loading={isSaving} loading={isSaving}
disabled={isSaving} disabled={isSaving}
form="access-controls-form" form="user-general-form"
> >
{t("accessControlsSubmit")} {t("saveSettings")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
@@ -9,15 +9,16 @@ 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"
}; };
interface UserLayoutProps { type 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;
@@ -42,15 +43,23 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [ const navItems = [
{ {
title: t("accessControls"), title: t("general"),
href: "/{orgId}/settings/access/users/{userId}/access-controls" href: "/{orgId}/settings/access/users/{userId}/general"
} }
]; ];
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title={`${user?.email}`} title={
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}/access-controls`); redirect(`/${orgId}/settings/access/users/${userId}/general`);
} }
@@ -1,24 +1,20 @@
"use client"; "use client";
import CopyTextBox from "@app/components/CopyTextBox";
import { import {
SettingsContainer, Credenza,
SettingsSection, CredenzaBody,
SettingsSectionBody, CredenzaContent,
SettingsSectionDescription, CredenzaDescription,
SettingsSectionForm, CredenzaFooter,
SettingsSectionHeader, CredenzaHeader,
SettingsSectionTitle CredenzaTitle
} from "@app/components/Settings"; } from "@app/components/Credenza";
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect"; import { 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 { import { useActionState, useRef, useState, startTransition } from "react";
useActionState,
useRef,
useState,
startTransition
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -42,7 +38,6 @@ 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";
@@ -55,7 +50,15 @@ 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 CopyToClipboard from "@app/components/CopyToClipboard"; import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
type UserType = "internal" | "oidc"; type UserType = "internal" | "oidc";
@@ -96,6 +99,7 @@ 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 }[]>([]);
@@ -246,9 +250,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< .get<AxiosResponse<ListIdpsResponse>>(
AxiosResponse<ListIdpsResponse> useOrgIdps ? `/org/${orgId}/idp` : "/idp"
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp") )
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast({ toast({
@@ -350,13 +354,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);
} }
} }
@@ -460,6 +464,7 @@ 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();
@@ -486,7 +491,7 @@ export default function Page() {
<div> <div>
<SettingsContainer> <SettingsContainer>
{!inviteLink && userOptions.length > 1 ? ( {userOptions.length > 1 ? (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -508,8 +513,6 @@ export default function Page() {
) : null} ) : null}
{selectedOption === "internal" && dataLoaded && ( {selectedOption === "internal" && dataLoaded && (
<>
{!inviteLink ? (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -533,9 +536,7 @@ export default function Page() {
id="create-user-form" id="create-user-form"
> >
<FormField <FormField
control={ control={internalForm.control}
internalForm.control
}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@@ -543,26 +544,40 @@ export default function Page() {
{t("email")} {t("email")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField {env.email.emailEnabled && (
control={ <div className="flex items-center space-x-2">
internalForm.control <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>
)}
<FormField
control={internalForm.control}
name="validForHours" name="validForHours"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("inviteValid")}
"inviteValid"
)}
</FormLabel> </FormLabel>
<Select <Select
onValueChange={ onValueChange={
@@ -619,66 +634,11 @@ export default function Page() {
invitePaywallMessage invitePaywallMessage
} }
/> />
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={
sendEmail
}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"inviteEmailSent"
)}
</label>
</div>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </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>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)} )}
{selectedOption && {selectedOption &&
@@ -898,24 +858,56 @@ 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={inviteLink ? "button" : "submit"} type="submit"
form={inviteLink ? undefined : "create-user-form"} form="create-user-form"
loading={loading} loading={loading}
disabled={loading} disabled={loading}
onClick={
inviteLink
? () =>
router.push(
`/${orgId}/settings/access/users`
)
: undefined
}
> >
{inviteLink ? t("done") : t("accessUserCreate")} {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,12 +78,13 @@ 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 = orgRoles.map( const roleFilterOptions = [
(r: ListRolesResponse["roles"][number]) => ({ { value: "owner", label: t("accessRoleOwner") },
...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(
@@ -126,9 +127,7 @@ 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: user.isOwner roleLabels: (() => {
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? []) const names = (user.roles ?? [])
.map((r) => r.roleName) .map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length)); .filter((n): n is string => Boolean(n?.length));
+74 -161
View File
@@ -43,10 +43,18 @@ 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();
@@ -58,6 +66,7 @@ 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>
>({}); >({});
@@ -75,22 +84,6 @@ 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: {
@@ -98,12 +91,9 @@ export default function Page() {
} }
}); });
const copiedForm = useForm({ function goToApiKeysList() {
resolver: zodResolver(copiedFormSchema), router.push(`/${orgId}/settings/api-keys`);
defaultValues: {
copied: true
} }
});
async function onSubmit(data: CreateFormValues) { async function onSubmit(data: CreateFormValues) {
setCreateLoading(true); setCreateLoading(true);
@@ -113,9 +103,10 @@ export default function Page() {
}; };
const res = await api const res = await api
.put< .put<AxiosResponse<CreateOrgApiKeyResponse>>(
AxiosResponse<CreateOrgApiKeyResponse> `/org/${orgId}/api-key/`,
>(`/org/${orgId}/api-key/`, payload) payload
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -125,16 +116,10 @@ export default function Page() {
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
const data = res.data.data; const created = 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/${data.apiKeyId}/actions`, { .post(`/org/${orgId}/api-key/${created.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter( actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key] (key) => selectedPermissions[key]
) )
@@ -149,27 +134,14 @@ export default function Page() {
}); });
if (actionsRes) { if (actionsRes) {
setApiKey(data); setApiKey(created);
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);
@@ -185,12 +157,7 @@ export default function Page() {
title={t("apiKeysCreate")} title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")} description={t("apiKeysCreateDescription")}
/> />
<Button <Button variant="outline" onClick={goToApiKeysList}>
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("apiKeysSeeAll")} {t("apiKeysSeeAll")}
</Button> </Button>
</div> </div>
@@ -198,8 +165,6 @@ export default function Page() {
{!loadingPage && ( {!loadingPage && (
<div> <div>
<SettingsContainer> <SettingsContainer>
{!apiKey && (
<>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -212,7 +177,7 @@ export default function Page() {
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh e.preventDefault();
} }
}} }}
className="space-y-4" className="space-y-4"
@@ -248,31 +213,60 @@ export default function Page() {
{t("apiKeysGeneralSettings")} {t("apiKeysGeneralSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t( {t("apiKeysGeneralSettingsDescription")}
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PermissionsSelectBox <PermissionsSelectBox
selectedPermissions={ selectedPermissions={selectedPermissions}
selectedPermissions
}
onChange={setSelectedPermissions} onChange={setSelectedPermissions}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</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 && ( {apiKey && (
<SettingsSection> <div className="space-y-4">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}> <InfoSections cols={2}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
@@ -289,9 +283,9 @@ export default function Page() {
{t("created")} {t("created")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{moment( {moment(apiKey.createdAt).format(
apiKey.createdAt "lll"
).format("lll")} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
@@ -306,98 +300,17 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div> </div>
)} )}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }
@@ -954,7 +954,6 @@ 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>
+70 -151
View File
@@ -46,6 +46,15 @@ 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();
@@ -56,6 +65,7 @@ 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>
>({}); >({});
@@ -73,22 +83,6 @@ 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: {
@@ -96,12 +90,9 @@ export default function Page() {
} }
}); });
const copiedForm = useForm({ function goToApiKeysList() {
resolver: zodResolver(copiedFormSchema), router.push(`/admin/api-keys`);
defaultValues: {
copied: true
} }
});
async function onSubmit(data: CreateFormValues) { async function onSubmit(data: CreateFormValues) {
setCreateLoading(true); setCreateLoading(true);
@@ -121,16 +112,10 @@ export default function Page() {
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
const data = res.data.data; const created = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api const actionsRes = await api
.post(`/api-key/${data.apiKeyId}/actions`, { .post(`/api-key/${created.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter( actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key] (key) => selectedPermissions[key]
) )
@@ -145,21 +130,14 @@ export default function Page() {
}); });
if (actionsRes) { if (actionsRes) {
setApiKey(data); setApiKey(created);
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);
@@ -175,12 +153,7 @@ export default function Page() {
title={t("apiKeysCreate")} title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")} description={t("apiKeysCreateDescription")}
/> />
<Button <Button variant="outline" onClick={goToApiKeysList}>
variant="outline"
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("apiKeysSeeAll")} {t("apiKeysSeeAll")}
</Button> </Button>
</div> </div>
@@ -188,8 +161,6 @@ export default function Page() {
{!loadingPage && ( {!loadingPage && (
<div> <div>
<SettingsContainer> <SettingsContainer>
{!apiKey && (
<>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -202,7 +173,7 @@ export default function Page() {
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh e.preventDefault();
} }
}} }}
className="space-y-4" className="space-y-4"
@@ -238,32 +209,61 @@ export default function Page() {
{t("apiKeysGeneralSettings")} {t("apiKeysGeneralSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t( {t("apiKeysGeneralSettingsDescription")}
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PermissionsSelectBox <PermissionsSelectBox
root={true} root={true}
selectedPermissions={ selectedPermissions={selectedPermissions}
selectedPermissions
}
onChange={setSelectedPermissions} onChange={setSelectedPermissions}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</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 && ( {apiKey && (
<SettingsSection> <div className="space-y-4">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}> <InfoSections cols={2}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
@@ -280,9 +280,9 @@ export default function Page() {
{t("created")} {t("created")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{moment( {moment(apiKey.createdAt).format(
apiKey.createdAt "lll"
).format("lll")} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
@@ -297,98 +297,17 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div> </div>
)} )}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }
+59
View File
@@ -0,0 +1,59 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgsTable from "@app/components/OrgsTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { AdminListOrgsResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Organizations"
};
export const dynamic = "force-dynamic";
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string>>;
};
export default async function OrganizationsPage(props: OrganizationsPageProps) {
const searchParams = new URLSearchParams(await props.searchParams);
let orgs: AdminListOrgsResponse["orgs"] = [];
let pagination: AdminListOrgsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
`/admin/orgs?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
orgs = responseData.orgs;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("orgsManage")}
description={t("orgsDescription")}
/>
<OrgsTable
orgs={orgs}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
+1 -1
View File
@@ -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);
+7 -1
View File
@@ -6,6 +6,7 @@ import {
Bot, Bot,
Boxes, Boxes,
Building2, Building2,
Building2Icon,
Cable, Cable,
ChartLine, ChartLine,
Coins, Coins,
@@ -376,6 +377,11 @@ 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",
@@ -392,7 +398,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
} }
] ]
: []), : []),
...(build == "enterprise" ...(build === "enterprise"
? [ ? [
{ {
title: "sidebarLicense", title: "sidebarLicense",
+227 -2
View File
@@ -19,7 +19,8 @@ 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";
@@ -43,6 +44,13 @@ 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;
@@ -90,6 +98,11 @@ 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 {
@@ -109,6 +122,11 @@ 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
@@ -184,6 +202,54 @@ 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({
@@ -235,7 +301,32 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} },
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.username}{" "}
{row.original.id === user.user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.serverAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ShieldUserIcon className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
}, },
{ {
accessorKey: "email", accessorKey: "email",
@@ -341,6 +432,37 @@ 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,
@@ -369,11 +491,34 @@ 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>
@@ -435,6 +580,86 @@ 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}
+1 -1
View File
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
<div className="inline-flex items-start md:items-center gap-2"> <div className="inline-flex items-start md:items-center gap-2">
<span> <span>
<Link <Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`} href={`/${orgId}/settings/access/users/${approval.user.userId}/general`}
className="text-primary hover:underline cursor-pointer" className="text-primary hover:underline cursor-pointer"
> >
{getUserDisplayName({ {getUserDisplayName({
+41 -96
View File
@@ -1,15 +1,12 @@
"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 {
ShieldCheck, SettingsSection,
Check, SettingsSectionBody,
Ban, SettingsSectionFooter
User, } from "@app/components/Settings";
Settings, import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
ArrowRight
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
@@ -21,102 +18,50 @@ export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
const t = useTranslations(); const t = useTranslations();
return ( return (
<div className="flex flex-col gap-6"> <SettingsSection>
<Card> <SettingsSectionBody>
<CardContent className="p-6 md:p-12"> <div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto"> <ShieldCheck className="h-8 w-8 text-primary" />
<div className="space-y-2"> <h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
<h3 className="text-xl md:text-2xl font-semibold">
{t("approvalsEmptyStateTitle")} {t("approvalsEmptyStateTitle")}
</h3> </h2>
<p className="text-muted-foreground text-sm md:text-lg"> <p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStateDescription")} {t("approvalsEmptyStateDescription")}
</p> </p>
<div className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("approvalsEmptyStateHowToTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep1Description")}
</span>
</li>
<li className="flex items-start gap-2">
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep2Description")}
</span>
</li>
</ul>
</div> </div>
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4"> <p className="mt-8 text-sm text-muted-foreground max-w-lg">
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border"> {t("approvalsEmptyStatePreviewDescription")}
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep1Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep1Description"
)}
</p> </p>
</div> </div>
</div> </SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<div className="flex items-start gap-3 md:gap-4"> <Button asChild>
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0"> <Link href={`/${orgId}/settings/access/roles`}>
<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")} {t("approvalsEmptyStateButtonText")}
<ArrowRight className="w-4 h-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link> </Link>
</div> </Button>
</CardContent> </SettingsSectionFooter>
</Card> </SettingsSection>
</div>
); );
} }
+56
View File
@@ -0,0 +1,56 @@
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>
);
}
+18 -24
View File
@@ -1,19 +1,18 @@
"use client"; "use client";
import { import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Check, Languages } from "lucide-react";
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;
@@ -43,36 +42,31 @@ export default function LocaleSwitcherSelect({
const selected = items.find((item) => item.value === defaultValue); const selected = items.find((item) => item.value === defaultValue);
return ( return (
<DropdownMenu> <DropdownMenuSub>
<DropdownMenuTrigger asChild> <DropdownMenuSubTrigger
<Button className={cn(
variant="ghost" "[&_svg:not([class*='text-'])]:text-muted-foreground",
className={clsx(
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && "pointer-events-none" isPending && "pointer-events-none"
)} )}
aria-label={label} aria-label={label}
> >
<Languages className="text-muted-foreground h-4 w-4" /> <Languages className="mr-2 h-4 w-4" />
<span className="text-left flex-1"> <span>{selected?.label ?? label}</span>
{selected?.label ?? label} </DropdownMenuSubTrigger>
</span> <DropdownMenuSubContent className="min-w-[8rem]">
</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"
> >
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span> <span>{item.label}</span>
{item.value === defaultValue && (
<Check className="ml-auto h-4 w-4" />
)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuSubContent>
</DropdownMenu> </DropdownMenuSub>
); );
} }
+45
View File
@@ -0,0 +1,45 @@
"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>
);
}
+1 -9
View File
@@ -27,15 +27,7 @@ export function OrgSelector({
const selectedOrg = orgs?.find((org) => org.orgId === orgId); const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const picker = ( const picker = (
<OrgPicker <OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
orgId={orgId}
orgs={orgs}
contentClassName={
isCollapsed
? "w-[320px]"
: "w-[var(--radix-popover-trigger-width)]"
}
>
<div <div
role="combobox" role="combobox"
className={cn( className={cn(
+290
View File
@@ -0,0 +1,290 @@
"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"
/>
</>
);
}
+26 -16
View File
@@ -9,13 +9,16 @@ 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 { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react"; import { Check, Laptop, Moon, Sun, 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";
@@ -149,42 +152,49 @@ 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>
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
{userTheme === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{userTheme === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{userTheme === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">{t(userTheme)}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[8rem]">
{(["light", "dark", "system"] as const).map( {(["light", "dark", "system"] as const).map(
(themeOption) => ( (themeOption) => (
<DropdownMenuItem <DropdownMenuItem
key={themeOption} key={themeOption}
onClick={() => handleThemeChange(themeOption)} onClick={() =>
handleThemeChange(themeOption)
}
className="flex items-center gap-2"
> >
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize"> <span className="capitalize">
{t(themeOption)} {t(themeOption)}
</span> </span>
{userTheme === themeOption && ( {userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <Check className="ml-auto h-4 w-4" />
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)} )}
</DropdownMenuItem> </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 && (
+1 -3
View File
@@ -21,14 +21,12 @@ import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types"; import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
+47 -29
View File
@@ -52,9 +52,11 @@ 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 [sendEmail, setSendEmail] = useState(true); const [expiresInDays, setExpiresInDays] = useState(3);
const { env } = useEnvContext();
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [validHours, setValidHours] = useState(72); const [validHours, setValidHours] = useState(72);
const api = createApiClient(useEnvContext()); const api = createApiClient({ env });
const { org } = useOrgContext(); const { org } = useOrgContext();
const t = useTranslations(); const t = useTranslations();
@@ -71,10 +73,11 @@ export default function RegenerateInvitationForm({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSendEmail(true); setSendEmail(env.email.emailEnabled);
setValidHours(72); setValidHours(72);
setExpiresInDays(3);
} }
}, [open]); }, [open, env.email.emailEnabled]);
async function handleRegenerate() { async function handleRegenerate() {
if (!invitation) return; if (!invitation) return;
@@ -96,15 +99,16 @@ export default function RegenerateInvitationForm({
email: invitation.email, email: invitation.email,
roleIds: invitation.roleIds, roleIds: invitation.roleIds,
validHours, validHours,
sendEmail, sendEmail: env.email.emailEnabled && 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) { if (sendEmail && env.email.emailEnabled) {
toast({ toast({
variant: "default", variant: "default",
title: t("inviteRegenerated"), title: t("inviteRegenerated"),
@@ -127,9 +131,7 @@ export default function RegenerateInvitationForm({
onRegenerate({ onRegenerate({
id: invitation.id, id: invitation.id,
email: invitation.email, email: invitation.email,
expiresAt: new Date( expiresAt: new Date(res.data.data.expiresAt).toISOString(),
res.data.data.expiresAt
).toISOString(),
roleLabels: invitation.roleLabels, roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds roleIds: invitation.roleIds
}); });
@@ -174,20 +176,29 @@ export default function RegenerateInvitationForm({
> >
<CredenzaContent> <CredenzaContent>
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle> <CredenzaTitle>
{inviteLink
? t("inviteRegenerated")
: t("inviteRegenerate")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t("inviteRegenerateDescription")} {inviteLink
? sendEmail && env.email.emailEnabled
? t("inviteEmailSentDescription")
: t("inviteSentDescription")
: t("inviteRegenerateDescription")}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
{!inviteLink ? ( {!inviteLink ? (
<div> <div className="space-y-4">
<p> <div className="space-y-2">
{t("inviteQuestionRegenerate", { <Label>{t("email")}</Label>
email: invitation?.email || "" <p className="text-sm">{invitation?.email}</p>
})} </div>
</p>
<div className="flex items-center space-x-2 mt-4"> {env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="send-email" id="send-email"
checked={sendEmail} checked={sendEmail}
@@ -195,12 +206,17 @@ export default function RegenerateInvitationForm({
setSendEmail(e as boolean) setSendEmail(e as boolean)
} }
/> />
<label htmlFor="send-email"> <label
{t("inviteSentEmail")} htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label> </label>
</div> </div>
<div className="mt-4 space-y-2"> )}
<Label>{t("inviteValidityPeriod")}</Label>
<div className="space-y-2">
<Label>{t("inviteValid")}</Label>
<Select <Select
value={validHours.toString()} value={validHours.toString()}
onValueChange={(value) => onValueChange={(value) =>
@@ -209,9 +225,7 @@ export default function RegenerateInvitationForm({
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue <SelectValue
placeholder={t( placeholder={t("selectDuration")}
"inviteValidityPeriodSelect"
)}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -228,9 +242,13 @@ export default function RegenerateInvitationForm({
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4 max-w-md"> <div className="space-y-4">
<p>{t("inviteRegenerateMessage")}</p> <p>
<CopyTextBox text={inviteLink} wrapText={false} /> {t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
</div> </div>
)} )}
</CredenzaBody> </CredenzaBody>
@@ -249,7 +267,7 @@ export default function RegenerateInvitationForm({
</> </>
) : ( ) : (
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button> <Button>{t("done")}</Button>
</CredenzaClose> </CredenzaClose>
)} )}
</CredenzaFooter> </CredenzaFooter>
-1
View File
@@ -52,7 +52,6 @@ import {
} from "./ui/controlled-data-table"; } from "./ui/controlled-data-table";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { durationToMs } from "@app/lib/durationToMs";
import { orgQueries, productUpdatesQueries } from "@app/lib/queries"; import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import semver from "semver"; import semver from "semver";
+9 -4
View File
@@ -8,27 +8,32 @@ 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 && ( {overflow.length > 0 && <OverflowRolesPopover labels={overflow} />}
<OverflowRolesPopover labels={overflow} />
)}
</div> </div>
); );
} }
+49 -4
View File
@@ -22,6 +22,7 @@ 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";
@@ -33,12 +34,20 @@ 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;
@@ -95,7 +104,13 @@ export default function UsersTable({
const roleIdsFromSearchParams = useMemo(() => { const roleIdsFromSearchParams = useMemo(() => {
const sp = new URLSearchParams(searchParams); const sp = new URLSearchParams(searchParams);
return [ return [
...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id))) ...new Set(
sp
.getAll("role_id")
.filter(
(id) => /^\d+$/.test(id) || id === OWNER_FILTER_VALUE
)
)
]; ];
}, [searchParams.toString()]); }, [searchParams.toString()]);
@@ -126,7 +141,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)) { if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) {
sp.append("role_id", id); sp.append("role_id", id);
} }
} }
@@ -171,7 +186,32 @@ 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">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.isOwner && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Crown className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("accessRoleOwner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
}, },
{ {
accessorKey: "idpName", accessorKey: "idpName",
@@ -222,7 +262,12 @@ export default function UsersTable({
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
return <UserRoleBadges roleLabels={row.original.roleLabels} />; return (
<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,6 +9,7 @@ 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";
@@ -81,8 +82,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 text-muted-foreground"> <div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin" /> <LoadingDots size="sm" />
</div> </div>
); );
} }
@@ -8,6 +8,7 @@ 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";
@@ -109,8 +110,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 text-muted-foreground"> <div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin" /> <LoadingDots size="sm" />
</div> </div>
); );
} }
@@ -22,6 +22,7 @@ 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";
@@ -169,8 +170,8 @@ export function LauncherGroupSection({
<CollapsibleContent className="w-full"> <CollapsibleContent className="w-full">
{showInitialLoader ? ( {showInitialLoader ? (
<div className="flex items-center justify-center py-10 text-muted-foreground"> <div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin" /> <LoadingDots size="sm" />
</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,6 +1,8 @@
"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,
@@ -28,7 +30,23 @@ 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
@@ -36,7 +54,10 @@ 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 { LauncherResource } from "@server/routers/launcher/types"; import type {
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";
@@ -44,15 +65,18 @@ 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;
@@ -60,6 +84,7 @@ type LauncherResourcePanelProps = {
resource: LauncherResource | null; resource: LauncherResource | null;
orgId: string; orgId: string;
isAdmin: boolean; isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}; };
type LauncherResourceDetailResult = type LauncherResourceDetailResult =
@@ -153,6 +178,249 @@ 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();
@@ -235,12 +503,16 @@ 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 || "";
@@ -328,6 +600,12 @@ 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}
@@ -363,14 +641,19 @@ 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">
@@ -417,6 +700,19 @@ 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
@@ -440,11 +736,15 @@ 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({
@@ -477,6 +777,8 @@ function LauncherResourcePanelBody({
launcherResource={resource} launcherResource={resource}
resource={detail.data} resource={detail.data}
authInfo={detail.authInfo} authInfo={detail.authInfo}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/> />
); );
} }
@@ -486,6 +788,8 @@ function LauncherResourcePanelBody({
orgId={orgId} orgId={orgId}
launcherResource={resource} launcherResource={resource}
resource={detail.data} resource={detail.data}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/> />
); );
} }
@@ -495,7 +799,8 @@ export function LauncherResourcePanel({
onOpenChange, onOpenChange,
resource, resource,
orgId, orgId,
isAdmin isAdmin,
onFilterBySite
}: LauncherResourcePanelProps) { }: LauncherResourcePanelProps) {
const t = useTranslations(); const t = useTranslations();
@@ -511,6 +816,8 @@ export function LauncherResourcePanel({
orgId={orgId} orgId={orgId}
resource={resource} resource={resource}
open={open} open={open}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/> />
) : null} ) : null}
</SidePanelBody> </SidePanelBody>
@@ -44,6 +44,7 @@ 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
@@ -584,6 +585,14 @@ 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(() => {
@@ -795,6 +804,7 @@ export default function ResourceLauncher({
resource={selectedResource} resource={selectedResource}
orgId={orgId} orgId={orgId}
isAdmin={isAdmin} isAdmin={isAdmin}
onFilterBySite={handleFilterBySite}
/> />
{activeSavedView ? ( {activeSavedView ? (
@@ -961,7 +961,6 @@ export function PolicyAccessRulesTable({
colSpan={columns.length} colSpan={columns.length}
message={t("rulesNoOne")} message={t("rulesNoOne")}
action={emptyStateAction} action={emptyStateAction}
compact
/> />
)} )}
</TableBody> </TableBody>
+3 -3
View File
@@ -81,15 +81,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
<span className="absolute inset-0 flex items-center justify-center"> <span className="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 bg-current animate-dot-pulse" className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }} style={{ animationDelay: "0ms" }}
/> />
<span <span
className="h-1 w-1 bg-current animate-dot-pulse" className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
/> />
<span <span
className="h-1 w-1 bg-current animate-dot-pulse" className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
/> />
</span> </span>
+16 -22
View File
@@ -5,52 +5,46 @@ 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 <div
className={`relative w-full overflow-hidden ${minHeightClass}`} className="pointer-events-none absolute inset-0 flex flex-col justify-start opacity-50"
>
<div
className="absolute inset-0 flex flex-col justify-start"
aria-hidden aria-hidden
> >
{Array.from({ length: placeholderRows }).map((_, i) => ( {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
<div key={i} className="h-10 shrink-0" /> (_, i) => (
))}
</div>
<div <div
className={`relative flex w-full flex-col items-center justify-center px-4 ${ key={i}
compact className="flex h-10 shrink-0 items-center border-b border-border/60 px-4 last:border-b-0"
? "min-h-[4.5rem] gap-3 py-4"
: "min-h-[11rem] gap-4 py-8"
}`}
> >
<div className="h-3.5 w-full rounded bg-muted/60" />
</div>
)
)}
</div>
<div className="relative flex w-full flex-col items-center justify-center px-4 min-h-[12.5rem] 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>
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"paths": { "paths": {
"@server/*": [ "@server/*": [