Compare commits

...

16 Commits

Author SHA1 Message Date
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 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
30 changed files with 975 additions and 170 deletions
+3
View File
@@ -465,6 +465,8 @@
"apiKeysDelete": "Delete API Key", "apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys", "apiKeysManage": "Manage API Keys",
"apiKeysDescription": "API keys are used to authenticate with the integration API", "apiKeysDescription": "API keys are used to authenticate with the integration API",
"orgsManage": "Manage Organizations",
"orgsDescription": "View and manage all organizations on this instance",
"provisioningKeysTitle": "Provisioning Key", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
@@ -2095,6 +2097,7 @@
"resourceBudgetSettings": "Budget", "resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
"sidebarAllUsers": "All Users", "sidebarAllUsers": "All Users",
+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;
} }
+6 -1
View File
@@ -2478,7 +2478,12 @@ hybridRouter.post(
destinations: destinations destinations: destinations
}); });
} catch (error) { } catch (error) {
logger.error(error); if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error);
}
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -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) {
+6 -2
View File
@@ -691,7 +691,9 @@ export async function verifyResourceSession(
); );
resourceSession = result?.resourceSession; resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5); if (resourceSession) {
localCache.set(sessionCacheKey, resourceSession, 5);
}
} }
if (resourceSession?.isRequestToken) { if (resourceSession?.isRequestToken) {
@@ -1121,7 +1123,9 @@ async function allowAccessToken(
resource.resourceId resource.resourceId
); );
resourceSession = result?.resourceSession; resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5); if (resourceSession) {
localCache.set(sessionCacheKey, resourceSession, 5);
}
} }
if ( if (
+6
View File
@@ -87,6 +87,12 @@ authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
authenticated.delete(
"/admin/org/:orgId",
verifyUserIsServerAdmin,
org.adminDeleteOrg
);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get( authenticated.get(
@@ -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";
+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);
} }
} }
+59
View File
@@ -0,0 +1,59 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgsTable from "@app/components/OrgsTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { AdminListOrgsResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Organizations"
};
export const dynamic = "force-dynamic";
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string>>;
};
export default async function OrganizationsPage(props: OrganizationsPageProps) {
const searchParams = new URLSearchParams(await props.searchParams);
let orgs: AdminListOrgsResponse["orgs"] = [];
let pagination: AdminListOrgsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
`/admin/orgs?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
orgs = responseData.orgs;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("orgsManage")}
description={t("orgsDescription")}
/>
<OrgsTable
orgs={orgs}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
+7 -1
View File
@@ -6,6 +6,7 @@ import {
Bot, Bot,
Boxes, Boxes,
Building2, Building2,
Building2Icon,
Cable, Cable,
ChartLine, ChartLine,
Coins, Coins,
@@ -381,6 +382,11 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/api-keys", href: "/admin/api-keys",
icon: <KeyRound className="size-4 flex-none" /> icon: <KeyRound className="size-4 flex-none" />
}, },
{
title: "sidebarOrgs",
href: "/admin/organizations",
icon: <Building2Icon className="size-4 flex-none" />
},
...(build === "oss" || ...(build === "oss" ||
env?.app.identityProviderMode === "global" || env?.app.identityProviderMode === "global" ||
env?.app.identityProviderMode === undefined env?.app.identityProviderMode === undefined
@@ -392,7 +398,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
} }
] ]
: []), : []),
...(build == "enterprise" ...(build === "enterprise"
? [ ? [
{ {
title: "sidebarLicense", title: "sidebarLicense",
+19 -25
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,23 +42,18 @@ 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( isPending && "pointer-events-none"
"w-full rounded-sm h-8 gap-2 justify-start font-normal", )}
isPending && "pointer-events-none" aria-label={label}
)} >
aria-label={label} <Languages className="mr-2 h-4 w-4" />
> <span>{selected?.label ?? label}</span>
<Languages className="text-muted-foreground h-4 w-4" /> </DropdownMenuSubTrigger>
<span className="text-left flex-1"> <DropdownMenuSubContent className="min-w-[8rem]">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => ( {items.map((item) => (
<DropdownMenuItem <DropdownMenuItem
key={item.value} key={item.value}
@@ -72,7 +66,7 @@ export default function LocaleSwitcherSelect({
<span>{item.label}</span> <span>{item.label}</span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuSubContent>
</DropdownMenu> </DropdownMenuSub>
); );
} }
+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(
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import type { AdminOrgRow } from "@server/routers/org";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon
} from "lucide-react";
import moment from "moment";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
type OrgTableProps = {
orgs: AdminOrgRow[];
pagination: PaginationState;
rowCount: number;
};
export default function OrgsTable({
orgs,
pagination,
rowCount
}: OrgTableProps) {
const router = useRouter();
const t = useTranslations();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
const api = createApiClient(useEnvContext());
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
function sortableHeader(column: string, label: string) {
const sortOrder = getSortDirection(column, searchParams);
const Icon =
sortOrder === "asc"
? ArrowDown01Icon
: sortOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort(column)}
>
{label}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
return [
{
accessorKey: "name",
friendlyName: t("name"),
enableHiding: false,
header: () => sortableHeader("name", t("name"))
},
{
accessorKey: "orgId",
friendlyName: t("orgId"),
header: () => <span className="p-3">{t("orgId")}</span>
},
{
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"
/>
</>
);
}
+39 -29
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>
{(["light", "dark", "system"] as const).map( <DropdownMenuSub>
(themeOption) => ( <DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
<DropdownMenuItem {userTheme === "light" && (
key={themeOption} <Sun className="mr-2 h-4 w-4" />
onClick={() => handleThemeChange(themeOption)} )}
> {userTheme === "dark" && (
{themeOption === "light" && ( <Moon className="mr-2 h-4 w-4" />
<Sun className="mr-2 h-4 w-4" /> )}
)} {userTheme === "system" && (
{themeOption === "dark" && ( <Laptop className="mr-2 h-4 w-4" />
<Moon className="mr-2 h-4 w-4" /> )}
)} <span className="capitalize">{t(userTheme)}</span>
{themeOption === "system" && ( </DropdownMenuSubTrigger>
<Laptop className="mr-2 h-4 w-4" /> <DropdownMenuSubContent className="min-w-[8rem]">
)} {(["light", "dark", "system"] as const).map(
<span className="capitalize"> (themeOption) => (
{t(themeOption)} <DropdownMenuItem
</span> key={themeOption}
{userTheme === themeOption && ( onClick={() =>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> handleThemeChange(themeOption)
<span className="h-2 w-2 rounded-full bg-primary"></span> }
</span> className="flex items-center gap-2"
)} >
</DropdownMenuItem> {userTheme === themeOption && (
) <Check className="h-4 w-4" />
)} )}
<span className="capitalize">
{t(themeOption)}
</span>
</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";
-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";
+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/*": [