From e2cbe11a5f30455a3e86f119b112895227dcbbc4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 16 Jan 2026 14:19:36 -0800 Subject: [PATCH 01/27] Send error codes down to olm --- server/routers/olm/error.ts | 28 +++++++++++ server/routers/olm/getUserOlm.ts | 4 +- .../routers/olm/handleOlmRegisterMessage.ts | 49 +++++++++++++++++-- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 server/routers/olm/error.ts diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts new file mode 100644 index 00000000..978c1a1a --- /dev/null +++ b/server/routers/olm/error.ts @@ -0,0 +1,28 @@ +import { sendToClient } from "#dynamic/routers/ws"; +// Error codes for registration failures +export const OlmErrorCodes = { + OLM_NOT_FOUND: "OLM_NOT_FOUND", + CLIENT_ID_NOT_FOUND: "CLIENT_ID_NOT_FOUND", + CLIENT_NOT_FOUND: "CLIENT_NOT_FOUND", + CLIENT_BLOCKED: "CLIENT_BLOCKED", + ORG_NOT_FOUND: "ORG_NOT_FOUND", + USER_ID_NOT_FOUND: "USER_ID_NOT_FOUND", + INVALID_USER_SESSION: "INVALID_USER_SESSION", + USER_ID_MISMATCH: "USER_ID_MISMATCH", + ACCESS_POLICY_DENIED: "ACCESS_POLICY_DENIED" +} as const; + +// Helper function to send registration error +export async function sendOlmError( + code: string, + errorMessage: string, + olmId: string +) { + sendToClient(olmId, { + type: "olm/error", + data: { + code, + message: errorMessage + } + }); +} diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts index dc0bfde3..578438f8 100644 --- a/server/routers/olm/getUserOlm.ts +++ b/server/routers/olm/getUserOlm.ts @@ -8,8 +8,8 @@ import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; +// import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ @@ -101,7 +101,7 @@ export async function getUserOlm( const model = result.fingerprints?.deviceModel || null; const newName = getUserDeviceName(model, olm.name); - const responseData = blocked !== undefined + const responseData = blocked !== undefined ? { ...olm, name: newName, blocked } : { ...olm, name: newName }; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 46241603..0c69ce8d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -27,6 +27,7 @@ import config from "@server/lib/config"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; +import { OlmErrorCodes, sendOlmError } from "./error"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); @@ -53,6 +54,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!olm.clientId) { logger.warn("Olm client ID not found"); + sendOlmError( + OlmErrorCodes.CLIENT_ID_NOT_FOUND, + "Olm client ID not found", + olm.olmId + ); return; } @@ -64,11 +70,23 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!client) { logger.warn("Client ID not found"); + sendOlmError( + OlmErrorCodes.CLIENT_NOT_FOUND, + "Client not found in organization", + olm.olmId + ); return; } if (client.blocked) { - logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); + logger.debug( + `Client ${client.clientId} is blocked. Ignoring register.` + ); + sendOlmError( + OlmErrorCodes.CLIENT_BLOCKED, + "Client is blocked", + olm.olmId + ); return; } @@ -80,12 +98,22 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!org) { logger.warn("Org not found"); + sendOlmError( + OlmErrorCodes.ORG_NOT_FOUND, + "Organization not found", + olm.olmId + ); return; } if (orgId) { if (!olm.userId) { logger.warn("Olm has no user ID"); + sendOlmError( + OlmErrorCodes.USER_ID_NOT_FOUND, + "User ID not found for this client", + olm.olmId + ); return; } @@ -93,10 +121,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await validateSessionToken(userToken); if (!userSession || !user) { logger.warn("Invalid user session for olm register"); - return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + sendOlmError( + OlmErrorCodes.INVALID_USER_SESSION, + "Invalid or expired user session token", + olm.olmId + ); + return; } if (user.userId !== olm.userId) { logger.warn("User ID mismatch for olm register"); + sendOlmError( + OlmErrorCodes.USER_ID_MISMATCH, + "User ID does not match the authenticated session", + olm.olmId + ); return; } @@ -114,6 +152,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn( `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` ); + sendOlmError( + OlmErrorCodes.ACCESS_POLICY_DENIED, + `Access policy denied: ${policyCheck.error}`, + olm.olmId + ); return; } } @@ -151,7 +194,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .update(clients) .set({ pubKey: publicKey, - archived: false, + archived: false }) .where(eq(clients.clientId, client.clientId)); From a126494c12dc481bf084c80f5c79b1e5573a605f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 16 Jan 2026 14:37:06 -0800 Subject: [PATCH 02/27] Add pending --- server/routers/olm/error.ts | 1 + server/routers/olm/handleOlmRegisterMessage.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts index 978c1a1a..c02c571c 100644 --- a/server/routers/olm/error.ts +++ b/server/routers/olm/error.ts @@ -5,6 +5,7 @@ export const OlmErrorCodes = { CLIENT_ID_NOT_FOUND: "CLIENT_ID_NOT_FOUND", CLIENT_NOT_FOUND: "CLIENT_NOT_FOUND", CLIENT_BLOCKED: "CLIENT_BLOCKED", + CLIENT_PENDING: "CLIENT_PENDING", ORG_NOT_FOUND: "ORG_NOT_FOUND", USER_ID_NOT_FOUND: "USER_ID_NOT_FOUND", INVALID_USER_SESSION: "INVALID_USER_SESSION", diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 0c69ce8d..242fe345 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -90,6 +90,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + if (client.approvalState == "pending") { + logger.debug( + `Client ${client.clientId} approval is pending. Ignoring register.` + ); + sendOlmError( + OlmErrorCodes.CLIENT_PENDING, + "Client approval is pending", + olm.olmId + ); + return; + } + const [org] = await db .select() .from(orgs) From 9114dd5992568ab2db94ccfe5e5332d8fb31dc3c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 16 Jan 2026 14:57:54 -0800 Subject: [PATCH 03/27] Send terminate error messages --- server/lib/calculateUserClientsForOrgs.ts | 3 +++ server/private/routers/re-key/reGenerateClientSecret.ts | 6 +++++- server/routers/client/archiveClient.ts | 3 ++- server/routers/client/blockClient.ts | 3 ++- server/routers/client/deleteClient.ts | 3 ++- server/routers/client/terminate.ts | 8 +++++++- server/routers/olm/archiveUserOlm.ts | 3 ++- server/routers/olm/deleteUserOlm.ts | 3 +++ server/routers/olm/error.ts | 8 +++++++- server/routers/olm/handleOlmPingMessage.ts | 3 +++ server/routers/org/deleteOrg.ts | 6 +++++- 11 files changed, 41 insertions(+), 8 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 0b4a131a..123aefdd 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -19,6 +19,7 @@ import logger from "@server/logger"; import { sendTerminateClient } from "@server/routers/client/terminate"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; +import { OlmErrorCodes } from "@server/routers/olm/error"; export async function calculateUserClientsForOrgs( userId: string, @@ -305,6 +306,8 @@ async function cleanupOrphanedClients( if (deletedClient.olmId) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, + "Deleted", deletedClient.olmId ); } diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts index 5478c690..a16a0646 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { disconnectClient, sendToClient } from "#private/routers/ws"; +import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error"; const reGenerateSecretParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -119,7 +120,10 @@ export async function reGenerateClientSecret( if (disconnect) { const payload = { type: `olm/terminate`, - data: {} + data: { + code: OlmErrorCodes.TERMINATED_REKEYED, + message: "Client secret has been regenerated" + } }; // Don't await this to prevent blocking the response sendToClient(existingOlms[0].olmId, payload).catch((error) => { diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts index 330f6ed8..5b1d65df 100644 --- a/server/routers/client/archiveClient.ts +++ b/server/routers/client/archiveClient.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const archiveClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -82,7 +83,7 @@ export async function archiveClient( // Send terminate signal if there's an associated OLM if (client.olmId) { - await sendTerminateClient(client.clientId, client.olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, "Archived", client.olmId); } }); diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts index 68ae64f8..3bb878a5 100644 --- a/server/routers/client/blockClient.ts +++ b/server/routers/client/blockClient.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const blockClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -78,7 +79,7 @@ export async function blockClient( // Send terminate signal if there's an associated OLM and it's connected if (client.olmId && client.online) { - await sendTerminateClient(client.clientId, client.olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, "Blocked", client.olmId); } }); diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index a16a2996..db88c365 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const deleteClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -91,7 +92,7 @@ export async function deleteClient( await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { - await sendTerminateClient(deletedClient.clientId, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion + await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, "Deleted", olm.olmId); // the olmId needs to be provided because it cant look it up after deletion } }); diff --git a/server/routers/client/terminate.ts b/server/routers/client/terminate.ts index 1cfdc709..5c5ca216 100644 --- a/server/routers/client/terminate.ts +++ b/server/routers/client/terminate.ts @@ -1,9 +1,12 @@ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms } from "@server/db"; import { eq } from "drizzle-orm"; +import { OlmErrorCodes } from "../olm/error"; export async function sendTerminateClient( clientId: number, + code: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], + message: string, olmId?: string | null ) { if (!olmId) { @@ -20,6 +23,9 @@ export async function sendTerminateClient( await sendToClient(olmId, { type: `olm/terminate`, - data: {} + data: { + code, + message + } }); } diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts index 46abd1a1..a835dbdc 100644 --- a/server/routers/olm/archiveUserOlm.ts +++ b/server/routers/olm/archiveUserOlm.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ @@ -52,7 +53,7 @@ export async function archiveUserOlm( .where(eq(clients.clientId, client.clientId)); await rebuildClientAssociationsFromClient(client, trx); - await sendTerminateClient(client.clientId, olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, "Archived", olmId); } // Archive the OLM (set archived to true) diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 83a3d16f..3d9e5c23 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -11,6 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ @@ -76,6 +77,8 @@ export async function deleteUserOlm( if (olm) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, + "Deleted", olm.olmId ); // the olmId needs to be provided because it cant look it up after deletion } diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts index c02c571c..11d3d4cf 100644 --- a/server/routers/olm/error.ts +++ b/server/routers/olm/error.ts @@ -10,7 +10,13 @@ export const OlmErrorCodes = { USER_ID_NOT_FOUND: "USER_ID_NOT_FOUND", INVALID_USER_SESSION: "INVALID_USER_SESSION", USER_ID_MISMATCH: "USER_ID_MISMATCH", - ACCESS_POLICY_DENIED: "ACCESS_POLICY_DENIED" + ACCESS_POLICY_DENIED: "ACCESS_POLICY_DENIED", + TERMINATED_REKEYED: "TERMINATED_REKEYED", + TERMINATED_ORG_DELETED: "TERMINATED_ORG_DELETED", + TERMINATED_INACTIVITY: "TERMINATED_INACTIVITY", + TERMINATED_DELETED: "TERMINATED_DELETED", + TERMINATED_ARCHIVED: "TERMINATED_ARCHIVED", + TERMINATED_BLOCKED: "TERMINATED_BLOCKED" } as const; // Helper function to send registration error diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index bfcb7f33..fc1ebb3f 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -10,6 +10,7 @@ import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { sendOlmSyncMessage } from "./sync"; +import { OlmErrorCodes } from "./error"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; @@ -64,6 +65,8 @@ export const startOlmOfflineChecker = (): void => { try { await sendTerminateClient( offlineClient.clientId, + OlmErrorCodes.TERMINATED_INACTIVITY, + "Client terminated due to inactivity", offlineClient.olmId ); // terminate first // wait a moment to ensure the message is sent diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 35dc7503..a4b913e8 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -21,6 +21,7 @@ import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; +import { OlmErrorCodes } from "../olm/error"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -208,7 +209,10 @@ export async function deleteOrg( for (const olmId of olmsToTerminate) { sendToClient(olmId, { type: "olm/terminate", - data: {} + data: { + code: OlmErrorCodes.TERMINATED_REKEYED, + message: "Organization has been deleted" + } }).catch((error) => { logger.error( "Failed to send termination message to olm:", From 888f5f8bb668575edd74f8c61658aa3d9fcb82a6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 16 Jan 2026 17:06:16 -0800 Subject: [PATCH 04/27] Dont terminate on archive --- Dockerfile | 2 ++ server/routers/client/archiveClient.ts | 5 ----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d0a0f68..07371f77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,8 @@ COPY ./cli/wrapper.sh /usr/local/bin/pangctl RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs COPY server/db/names.json ./dist/names.json +COPY server/db/ios_models.json ./dist/ios_models.json +COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public # OCI Image Labels diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts index 5b1d65df..621a0acf 100644 --- a/server/routers/client/archiveClient.ts +++ b/server/routers/client/archiveClient.ts @@ -80,11 +80,6 @@ export async function archiveClient( // Rebuild associations to clean up related data await rebuildClientAssociationsFromClient(client, trx); - - // Send terminate signal if there's an associated OLM - if (client.olmId) { - await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, "Archived", client.olmId); - } }); return response(res, { From ce632a25cf80fb33a36af8d5ee767703e2b35ca0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 17 Jan 2026 11:41:10 -0800 Subject: [PATCH 05/27] Consolidate the messages into the same enum --- server/lib/calculateUserClientsForOrgs.ts | 1 - .../routers/re-key/reGenerateClientSecret.ts | 14 ++- server/routers/client/blockClient.ts | 2 +- server/routers/client/deleteClient.ts | 2 +- server/routers/client/terminate.ts | 7 +- server/routers/olm/archiveUserOlm.ts | 2 +- server/routers/olm/deleteUserOlm.ts | 1 - server/routers/olm/error.ts | 87 ++++++++++++++----- server/routers/olm/handleOlmPingMessage.ts | 1 - .../routers/olm/handleOlmRegisterMessage.ts | 9 -- server/routers/org/deleteOrg.ts | 13 ++- 11 files changed, 85 insertions(+), 54 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 123aefdd..b2ea08a3 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -307,7 +307,6 @@ async function cleanupOrphanedClients( await sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, - "Deleted", deletedClient.olmId ); } diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts index a16a0646..b2f9e151 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -25,6 +25,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { disconnectClient, sendToClient } from "#private/routers/ws"; import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; const reGenerateSecretParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -118,15 +119,12 @@ export async function reGenerateClientSecret( // Only disconnect if explicitly requested if (disconnect) { - const payload = { - type: `olm/terminate`, - data: { - code: OlmErrorCodes.TERMINATED_REKEYED, - message: "Client secret has been regenerated" - } - }; // Don't await this to prevent blocking the response - sendToClient(existingOlms[0].olmId, payload).catch((error) => { + sendTerminateClient( + clientId, + OlmErrorCodes.TERMINATED_REKEYED, + existingOlms[0].olmId + ).catch((error) => { logger.error( "Failed to send termination message to olm:", error diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts index 3bb878a5..bd760b3d 100644 --- a/server/routers/client/blockClient.ts +++ b/server/routers/client/blockClient.ts @@ -79,7 +79,7 @@ export async function blockClient( // Send terminate signal if there's an associated OLM and it's connected if (client.olmId && client.online) { - await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, "Blocked", client.olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); } }); diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index db88c365..276bfde9 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -92,7 +92,7 @@ export async function deleteClient( await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { - await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, "Deleted", olm.olmId); // the olmId needs to be provided because it cant look it up after deletion + await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion } }); diff --git a/server/routers/client/terminate.ts b/server/routers/client/terminate.ts index 5c5ca216..db9cfcb0 100644 --- a/server/routers/client/terminate.ts +++ b/server/routers/client/terminate.ts @@ -5,8 +5,7 @@ import { OlmErrorCodes } from "../olm/error"; export async function sendTerminateClient( clientId: number, - code: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], - message: string, + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId?: string | null ) { if (!olmId) { @@ -24,8 +23,8 @@ export async function sendTerminateClient( await sendToClient(olmId, { type: `olm/terminate`, data: { - code, - message + code: error.code, + message: error.message } }); } diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts index a835dbdc..b1a7bb4d 100644 --- a/server/routers/olm/archiveUserOlm.ts +++ b/server/routers/olm/archiveUserOlm.ts @@ -53,7 +53,7 @@ export async function archiveUserOlm( .where(eq(clients.clientId, client.clientId)); await rebuildClientAssociationsFromClient(client, trx); - await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, "Archived", olmId); + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId); } // Archive the OLM (set archived to true) diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 3d9e5c23..2c281489 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -78,7 +78,6 @@ export async function deleteUserOlm( await sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, - "Deleted", olm.olmId ); // the olmId needs to be provided because it cant look it up after deletion } diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts index 11d3d4cf..289e7219 100644 --- a/server/routers/olm/error.ts +++ b/server/routers/olm/error.ts @@ -1,35 +1,82 @@ import { sendToClient } from "#dynamic/routers/ws"; // Error codes for registration failures export const OlmErrorCodes = { - OLM_NOT_FOUND: "OLM_NOT_FOUND", - CLIENT_ID_NOT_FOUND: "CLIENT_ID_NOT_FOUND", - CLIENT_NOT_FOUND: "CLIENT_NOT_FOUND", - CLIENT_BLOCKED: "CLIENT_BLOCKED", - CLIENT_PENDING: "CLIENT_PENDING", - ORG_NOT_FOUND: "ORG_NOT_FOUND", - USER_ID_NOT_FOUND: "USER_ID_NOT_FOUND", - INVALID_USER_SESSION: "INVALID_USER_SESSION", - USER_ID_MISMATCH: "USER_ID_MISMATCH", - ACCESS_POLICY_DENIED: "ACCESS_POLICY_DENIED", - TERMINATED_REKEYED: "TERMINATED_REKEYED", - TERMINATED_ORG_DELETED: "TERMINATED_ORG_DELETED", - TERMINATED_INACTIVITY: "TERMINATED_INACTIVITY", - TERMINATED_DELETED: "TERMINATED_DELETED", - TERMINATED_ARCHIVED: "TERMINATED_ARCHIVED", - TERMINATED_BLOCKED: "TERMINATED_BLOCKED" + OLM_NOT_FOUND: { + code: "OLM_NOT_FOUND", + message: "The requested OLM session could not be found." + }, + CLIENT_ID_NOT_FOUND: { + code: "CLIENT_ID_NOT_FOUND", + message: "No client ID was provided in the request." + }, + CLIENT_NOT_FOUND: { + code: "CLIENT_NOT_FOUND", + message: "The specified client does not exist." + }, + CLIENT_BLOCKED: { + code: "CLIENT_BLOCKED", + message: "This client has been blocked and cannot connect." + }, + CLIENT_PENDING: { + code: "CLIENT_PENDING", + message: "This client is pending approval and cannot connect yet." + }, + ORG_NOT_FOUND: { + code: "ORG_NOT_FOUND", + message: "The organization could not be found." + }, + USER_ID_NOT_FOUND: { + code: "USER_ID_NOT_FOUND", + message: "No user ID was provided in the request." + }, + INVALID_USER_SESSION: { + code: "INVALID_USER_SESSION", + message: "Your user session is invalid or has expired." + }, + USER_ID_MISMATCH: { + code: "USER_ID_MISMATCH", + message: "The provided user ID does not match the session." + }, + ACCESS_POLICY_DENIED: { + code: "ACCESS_POLICY_DENIED", + message: "Access denied due to policy restrictions." + }, + TERMINATED_REKEYED: { + code: "TERMINATED_REKEYED", + message: "This session was terminated because encryption keys were regenerated." + }, + TERMINATED_ORG_DELETED: { + code: "TERMINATED_ORG_DELETED", + message: "This session was terminated because the organization was deleted." + }, + TERMINATED_INACTIVITY: { + code: "TERMINATED_INACTIVITY", + message: "This session was terminated due to inactivity." + }, + TERMINATED_DELETED: { + code: "TERMINATED_DELETED", + message: "This session was terminated because it was deleted." + }, + TERMINATED_ARCHIVED: { + code: "TERMINATED_ARCHIVED", + message: "This session was terminated because it was archived." + }, + TERMINATED_BLOCKED: { + code: "TERMINATED_BLOCKED", + message: "This session was terminated because access was blocked." + } } as const; // Helper function to send registration error export async function sendOlmError( - code: string, - errorMessage: string, + error: typeof OlmErrorCodes[keyof typeof OlmErrorCodes], olmId: string ) { sendToClient(olmId, { type: "olm/error", data: { - code, - message: errorMessage + code: error.code, + message: error.message } }); } diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index fc1ebb3f..1fcd85b5 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -66,7 +66,6 @@ export const startOlmOfflineChecker = (): void => { await sendTerminateClient( offlineClient.clientId, OlmErrorCodes.TERMINATED_INACTIVITY, - "Client terminated due to inactivity", offlineClient.olmId ); // terminate first // wait a moment to ensure the message is sent diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 242fe345..a8c1327d 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -56,7 +56,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("Olm client ID not found"); sendOlmError( OlmErrorCodes.CLIENT_ID_NOT_FOUND, - "Olm client ID not found", olm.olmId ); return; @@ -72,7 +71,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("Client ID not found"); sendOlmError( OlmErrorCodes.CLIENT_NOT_FOUND, - "Client not found in organization", olm.olmId ); return; @@ -84,7 +82,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); sendOlmError( OlmErrorCodes.CLIENT_BLOCKED, - "Client is blocked", olm.olmId ); return; @@ -96,7 +93,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); sendOlmError( OlmErrorCodes.CLIENT_PENDING, - "Client approval is pending", olm.olmId ); return; @@ -112,7 +108,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("Org not found"); sendOlmError( OlmErrorCodes.ORG_NOT_FOUND, - "Organization not found", olm.olmId ); return; @@ -123,7 +118,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("Olm has no user ID"); sendOlmError( OlmErrorCodes.USER_ID_NOT_FOUND, - "User ID not found for this client", olm.olmId ); return; @@ -135,7 +129,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("Invalid user session for olm register"); sendOlmError( OlmErrorCodes.INVALID_USER_SESSION, - "Invalid or expired user session token", olm.olmId ); return; @@ -144,7 +137,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn("User ID mismatch for olm register"); sendOlmError( OlmErrorCodes.USER_ID_MISMATCH, - "User ID does not match the authenticated session", olm.olmId ); return; @@ -166,7 +158,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); sendOlmError( OlmErrorCodes.ACCESS_POLICY_DENIED, - `Access policy denied: ${policyCheck.error}`, olm.olmId ); return; diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index a4b913e8..48d3102d 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -22,6 +22,7 @@ import { sendToClient } from "#dynamic/routers/ws"; import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; import { OlmErrorCodes } from "../olm/error"; +import { sendTerminateClient } from "../client/terminate"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -207,13 +208,11 @@ export async function deleteOrg( } for (const olmId of olmsToTerminate) { - sendToClient(olmId, { - type: "olm/terminate", - data: { - code: OlmErrorCodes.TERMINATED_REKEYED, - message: "Organization has been deleted" - } - }).catch((error) => { + sendTerminateClient( + 0, // clientId not needed since we're passing olmId + OlmErrorCodes.TERMINATED_REKEYED, + olmId + ).catch((error) => { logger.error( "Failed to send termination message to olm:", error From 2f1756ccf2c297f33bdbe7105532f35568377243 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 17 Jan 2026 12:00:27 -0800 Subject: [PATCH 06/27] add more error messages for org access policy --- server/routers/olm/error.ts | 44 ++++++--- .../routers/olm/handleOlmRegisterMessage.ts | 99 +++++++++---------- 2 files changed, 80 insertions(+), 63 deletions(-) diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts index 289e7219..6ea209ce 100644 --- a/server/routers/olm/error.ts +++ b/server/routers/olm/error.ts @@ -3,7 +3,7 @@ import { sendToClient } from "#dynamic/routers/ws"; export const OlmErrorCodes = { OLM_NOT_FOUND: { code: "OLM_NOT_FOUND", - message: "The requested OLM session could not be found." + message: "The specified device could not be found." }, CLIENT_ID_NOT_FOUND: { code: "CLIENT_ID_NOT_FOUND", @@ -15,15 +15,18 @@ export const OlmErrorCodes = { }, CLIENT_BLOCKED: { code: "CLIENT_BLOCKED", - message: "This client has been blocked and cannot connect." + message: + "This client has been blocked in this organization and cannot connect. Please contact your administrator." }, CLIENT_PENDING: { code: "CLIENT_PENDING", - message: "This client is pending approval and cannot connect yet." + message: + "This client is pending approval and cannot connect yet. Please contact your administrator." }, ORG_NOT_FOUND: { code: "ORG_NOT_FOUND", - message: "The organization could not be found." + message: + "The organization could not be found. Please select a valid organization." }, USER_ID_NOT_FOUND: { code: "USER_ID_NOT_FOUND", @@ -31,23 +34,42 @@ export const OlmErrorCodes = { }, INVALID_USER_SESSION: { code: "INVALID_USER_SESSION", - message: "Your user session is invalid or has expired." + message: + "Your user session is invalid or has expired. Please log in again." }, USER_ID_MISMATCH: { code: "USER_ID_MISMATCH", message: "The provided user ID does not match the session." }, - ACCESS_POLICY_DENIED: { - code: "ACCESS_POLICY_DENIED", - message: "Access denied due to policy restrictions." + ORG_ACCESS_POLICY_DENIED: { + code: "ORG_ACCESS_POLICY_DENIED", + message: + "Access to this organization has been denied by policy. Please contact your administrator." + }, + ORG_ACCESS_POLICY_PASSWORD_EXPIRED: { + code: "ORG_ACCESS_POLICY_PASSWORD_EXPIRED", + message: + "Access to this organization has been denied because your password has expired. Please visit this organization's dashboard to update your password." + }, + ORG_ACCESS_POLICY_SESSION_EXPIRED: { + code: "ORG_ACCESS_POLICY_SESSION_EXPIRED", + message: + "Access to this organization has been denied because your session has expired. Please log in again to refresh the session." + }, + ORG_ACCESS_POLICY_2FA_REQUIRED: { + code: "ORG_ACCESS_POLICY_2FA_REQUIRED", + message: + "Access to this organization requires two-factor authentication. Please visit this organization's dashboard to enable two-factor authentication." }, TERMINATED_REKEYED: { code: "TERMINATED_REKEYED", - message: "This session was terminated because encryption keys were regenerated." + message: + "This session was terminated because encryption keys were regenerated." }, TERMINATED_ORG_DELETED: { code: "TERMINATED_ORG_DELETED", - message: "This session was terminated because the organization was deleted." + message: + "This session was terminated because the organization was deleted." }, TERMINATED_INACTIVITY: { code: "TERMINATED_INACTIVITY", @@ -69,7 +91,7 @@ export const OlmErrorCodes = { // Helper function to send registration error export async function sendOlmError( - error: typeof OlmErrorCodes[keyof typeof OlmErrorCodes], + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId: string ) { sendToClient(olmId, { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index a8c1327d..c8a8d3f7 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,29 +1,16 @@ -import { - Client, - clientPostureSnapshots, - clientSiteResourcesAssociationsCache, - db, - fingerprints, - orgs, - siteResources -} from "@server/db"; +import { clientPostureSnapshots, db, fingerprints, orgs } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, olms, sites } from "@server/db"; -import { and, count, eq, inArray, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; +import { count, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { generateAliasConfig } from "@server/lib/ip"; -import { generateRemoteSubnets } from "@server/lib/ip"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; @@ -54,10 +41,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!olm.clientId) { logger.warn("Olm client ID not found"); - sendOlmError( - OlmErrorCodes.CLIENT_ID_NOT_FOUND, - olm.olmId - ); + sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); return; } @@ -69,10 +53,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!client) { logger.warn("Client ID not found"); - sendOlmError( - OlmErrorCodes.CLIENT_NOT_FOUND, - olm.olmId - ); + sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId); return; } @@ -80,10 +61,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.debug( `Client ${client.clientId} is blocked. Ignoring register.` ); - sendOlmError( - OlmErrorCodes.CLIENT_BLOCKED, - olm.olmId - ); + sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); return; } @@ -91,10 +69,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.debug( `Client ${client.clientId} approval is pending. Ignoring register.` ); - sendOlmError( - OlmErrorCodes.CLIENT_PENDING, - olm.olmId - ); + sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); return; } @@ -106,20 +81,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!org) { logger.warn("Org not found"); - sendOlmError( - OlmErrorCodes.ORG_NOT_FOUND, - olm.olmId - ); + sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); return; } if (orgId) { if (!olm.userId) { logger.warn("Olm has no user ID"); - sendOlmError( - OlmErrorCodes.USER_ID_NOT_FOUND, - olm.olmId - ); + sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); return; } @@ -127,18 +96,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await validateSessionToken(userToken); if (!userSession || !user) { logger.warn("Invalid user session for olm register"); - sendOlmError( - OlmErrorCodes.INVALID_USER_SESSION, - olm.olmId - ); + sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); return; } if (user.userId !== olm.userId) { logger.warn("User ID mismatch for olm register"); - sendOlmError( - OlmErrorCodes.USER_ID_MISMATCH, - olm.olmId - ); + sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); return; } @@ -152,14 +115,46 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { sessionId // this is the user token passed in the message }); - if (!policyCheck.allowed) { + if (policyCheck?.error) { + logger.error( + `Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` + ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); + return; + } + + if (policyCheck?.policies?.passwordAge?.compliant) { + logger.warn( + `Olm user ${olm.userId} has non-compliant password age for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, + olm.olmId + ); + return; + } else if (policyCheck?.policies?.maxSessionLength?.compliant) { + logger.warn( + `Olm user ${olm.userId} has non-compliant session length for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, + olm.olmId + ); + return; + } else if (policyCheck?.policies?.requiredTwoFactor) { + logger.warn( + `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, + olm.olmId + ); + return; + } else if (!policyCheck.allowed) { logger.warn( `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` ); - sendOlmError( - OlmErrorCodes.ACCESS_POLICY_DENIED, - olm.olmId - ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); return; } } From 610b20c1ff24c3ac6312c9d93e8fbc79876df8f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 17 Jan 2026 12:21:18 -0800 Subject: [PATCH 07/27] Use the right driver Fixes #2254 --- install/containers.go | 41 +++++++++++++++++++++++++++++++++++++++++ install/crowdsec.go | 4 ++-- install/main.go | 11 ++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/install/containers.go b/install/containers.go index 464186c2..333fd890 100644 --- a/install/containers.go +++ b/install/containers.go @@ -210,6 +210,47 @@ func isDockerRunning() bool { return true } +func isPodmanRunning() bool { + cmd := exec.Command("podman", "info") + if err := cmd.Run(); err != nil { + return false + } + return true +} + +// detectContainerType detects whether the system is currently using Docker or Podman +// by checking which container runtime is running and has containers +func detectContainerType() SupportedContainer { + // Check if we have running containers with podman + if isPodmanRunning() { + cmd := exec.Command("podman", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Podman + } + } + + // Check if we have running containers with docker + if isDockerRunning() { + cmd := exec.Command("docker", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Docker + } + } + + // If no containers are running, check which one is installed and running + if isPodmanRunning() && isPodmanInstalled() { + return Podman + } + + if isDockerRunning() && isDockerInstalled() { + return Docker + } + + return Undefined +} + // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied func executeDockerComposeCommandWithArgs(args ...string) error { var cmd *exec.Cmd diff --git a/install/crowdsec.go b/install/crowdsec.go index 2e388e92..401ef215 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -93,7 +93,7 @@ func installCrowdsec(config Config) error { if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") { fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:") - fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer") + fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType) } return nil @@ -117,7 +117,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { } // Execute the command to get the API key - cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") + cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") var out bytes.Buffer cmd.Stdout = &out diff --git a/install/main.go b/install/main.go index a231da2d..6f4d4f35 100644 --- a/install/main.go +++ b/install/main.go @@ -229,7 +229,16 @@ func main() { } } - config.InstallationContainerType = podmanOrDocker(reader) + // Try to detect container type from existing installation + detectedType := detectContainerType() + if detectedType == Undefined { + // If detection fails, prompt the user + fmt.Println("Unable to detect container type from existing installation.") + config.InstallationContainerType = podmanOrDocker(reader) + } else { + config.InstallationContainerType = detectedType + fmt.Printf("Detected container type: %s\n", config.InstallationContainerType) + } config.DoCrowdsecInstall = true err := installCrowdsec(config) From f7cede4713e167e159bca61e9d14439d75618d17 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 17 Jan 2026 12:22:43 -0800 Subject: [PATCH 08/27] Use /etc/sysctl.d/99-podman.conf Fixes #2253 --- install/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/install/main.go b/install/main.go index 6f4d4f35..3ea6af22 100644 --- a/install/main.go +++ b/install/main.go @@ -295,10 +295,10 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { os.Exit(1) } - if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { + if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") - approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true) if approved { if os.Geteuid() != 0 { fmt.Println("You need to run the installer as root for such a configuration.") @@ -309,7 +309,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { // container low-range ports as unprivileged ports. // Linux only. - if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil { fmt.Printf("Error configuring unprivileged ports: %v\n", err) os.Exit(1) } From 34e2fbefb9abb1ddbabb06223d9e93d2e36df1a1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 17 Jan 2026 20:58:16 -0800 Subject: [PATCH 09/27] add view user device page with fingerprint and actions --- messages/en-US.json | 28 +- server/routers/client/getClient.ts | 35 +- .../clients/machine/[niceId]/general/page.tsx | 52 +- .../clients/user/[niceId]/general/page.tsx | 507 ++++++++++++++++++ .../settings/clients/user/[niceId]/layout.tsx | 57 ++ .../settings/clients/user/[niceId]/page.tsx | 10 + src/components/ActionBanner.tsx | 91 ++++ src/components/ClientInfoCard.tsx | 6 +- src/components/InfoSection.tsx | 2 +- src/components/MachineClientsTable.tsx | 28 +- src/components/UserDevicesTable.tsx | 30 +- src/providers/ClientProvider.tsx | 7 +- 12 files changed, 792 insertions(+), 61 deletions(-) create mode 100644 src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx create mode 100644 src/app/[orgId]/settings/clients/user/[niceId]/page.tsx create mode 100644 src/components/ActionBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3f1f8174..7b553976 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2483,5 +2483,31 @@ "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", "signupOrgLink": "Sign in or sign up with your organization instead", "verifyEmailLogInWithDifferentAccount": "Use a Different Account", - "logIn": "Log In" + "logIn": "Log In", + "deviceInformation": "Device Information", + "deviceInformationDescription": "Information about the device and agent", + "platform": "Platform", + "macosVersion": "macOS Version", + "windowsVersion": "Windows Version", + "iosVersion": "iOS Version", + "androidVersion": "Android Version", + "osVersion": "OS Version", + "kernelVersion": "Kernel Version", + "deviceModel": "Device Model", + "serialNumber": "Serial Number", + "hostname": "Hostname", + "firstSeen": "First Seen", + "lastSeen": "Last Seen", + "deviceSettingsDescription": "View device information and settings", + "devicePendingApprovalDescription": "This device is waiting for approval", + "deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.", + "unblockClient": "Unblock Client", + "unblockClientDescription": "The device has been unblocked", + "unarchiveClient": "Unarchive Client", + "unarchiveClientDescription": "The device has been unarchived", + "block": "Block", + "unblock": "Unblock", + "deviceActions": "Device Actions", + "deviceActionsDescription": "Manage device status and access", + "devicePendingApprovalBannerDescription": "This device is pending approval. It won't be able to connect to resources until approved." } diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 709eb1a5..7917e037 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -49,6 +49,20 @@ export type GetClientResponse = NonNullable< Awaited> >["clients"] & { olmId: string | null; + agent: string | null; + olmVersion: string | null; + fingerprint: { + username: string | null; + hostname: string | null; + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + firstSeen: number | null; + lastSeen: number | null; + } | null; }; registry.registerPath({ @@ -115,10 +129,29 @@ export async function getClient( clientName = getUserDeviceName(model, client.clients.name); } + // Build fingerprint data if available + const fingerprintData = client.fingerprints + ? { + username: client.fingerprints.username || null, + hostname: client.fingerprints.hostname || null, + platform: client.fingerprints.platform || null, + osVersion: client.fingerprints.osVersion || null, + kernelVersion: client.fingerprints.kernelVersion || null, + arch: client.fingerprints.arch || null, + deviceModel: client.fingerprints.deviceModel || null, + serialNumber: client.fingerprints.serialNumber || null, + firstSeen: client.fingerprints.firstSeen || null, + lastSeen: client.fingerprints.lastSeen || null + } + : null; + const data: GetClientResponse = { ...client.clients, name: clientName, - olmId: client.olms ? client.olms.olmId : null + olmId: client.olms ? client.olms.olmId : null, + agent: client.olms?.agent || null, + olmVersion: client.olms?.version || null, + fingerprint: fingerprintData }; return response(res, { diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index c2ef26e4..0e3d9b09 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -29,9 +29,11 @@ import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import ActionBanner from "@app/components/ActionBanner"; +import { Shield, ShieldOff } from "lucide-react"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -45,7 +47,9 @@ export default function GeneralPage() { const { client, updateClient } = useClientContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); + const [, startTransition] = useTransition(); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -109,8 +113,54 @@ export default function GeneralPage() { } } + const handleUnblock = async () => { + if (!client?.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + return ( + {/* Blocked Device Banner */} + {client?.blocked && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx new file mode 100644 index 00000000..179756e1 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import ActionBanner from "@app/components/ActionBanner"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { useState, useEffect, useTransition } from "react"; +import { Check, Ban, Shield, ShieldOff, Clock } from "lucide-react"; +import { useParams } from "next/navigation"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { SiAndroid } from "react-icons/si"; + +function formatTimestamp(timestamp: number | null | undefined): string { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleString(); +} + +function formatPlatform(platform: string | null | undefined): string { + if (!platform) return "-"; + const platformMap: Record = { + macos: "macOS", + windows: "Windows", + linux: "Linux", + ios: "iOS", + android: "Android", + unknown: "Unknown" + }; + return platformMap[platform.toLowerCase()] || platform; +} + +function getPlatformIcon(platform: string | null | undefined) { + if (!platform) return null; + const normalizedPlatform = platform.toLowerCase(); + switch (normalizedPlatform) { + case "macos": + case "ios": + return ; + case "windows": + return ; + case "linux": + return ; + case "android": + return ; + default: + return null; + } +} + +type FieldConfig = { + show: boolean; + labelKey: string; +}; + +function getPlatformFieldConfig( + platform: string | null | undefined +): Record { + const normalizedPlatform = platform?.toLowerCase() || "unknown"; + + const configs: Record> = { + macos: { + osVersion: { show: true, labelKey: "macosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + windows: { + osVersion: { show: true, labelKey: "windowsVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + linux: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + ios: { + osVersion: { show: true, labelKey: "iosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + android: { + osVersion: { show: true, labelKey: "androidVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + unknown: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + } + }; + + return configs[normalizedPlatform] || configs.unknown; +} + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const { isPaidUser } = usePaidStatus(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const params = useParams(); + const orgId = params.orgId as string; + const [approvalId, setApprovalId] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [, startTransition] = useTransition(); + + const showApprovalFeatures = build !== "oss" && isPaidUser; + + // Fetch approval ID for this client if pending + useEffect(() => { + if (showApprovalFeatures && client.approvalState === "pending" && client.clientId) { + api.get(`/org/${orgId}/approvals?approvalState=pending`) + .then((res) => { + const approval = res.data.data.approvals.find( + (a: any) => a.clientId === client.clientId + ); + if (approval) { + setApprovalId(approval.approvalId); + } + }) + .catch(() => { + // Silently fail - approval might not exist + }); + } + }, [showApprovalFeatures, client.approvalState, client.clientId, orgId, api]); + + const handleApprove = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "approved" + }); + // Optimistically update the client context + updateClient({ approvalState: "approved" }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalApprovedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleDeny = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "denied" + }); + // Optimistically update the client context + updateClient({ approvalState: "denied", blocked: true }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalDeniedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleBlock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/block`); + // Optimistically update the client context + updateClient({ blocked: true, approvalState: "denied" }); + toast({ + title: t("blockClient"), + description: t("blockClientMessage") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleUnblock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + + return ( + + {/* Pending Approval Banner */} + {showApprovalFeatures && client.approvalState === "pending" && ( + } + description={t("devicePendingApprovalBannerDescription")} + actions={ + <> + + + + } + /> + )} + + {/* Blocked Device Banner */} + {client.blocked && client.approvalState !== "pending" && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} + + {/* Device Information Section */} + {(client.fingerprint || + (client.agent && client.olmVersion)) && ( + + + + {t("deviceInformation")} + + + {t("deviceInformationDescription")} + + + + + {client.agent && client.olmVersion && ( +
+ + + {t("agent")} + + + + {client.agent + " v" + client.olmVersion} + + + +
+ )} + + {client.fingerprint && (() => { + const platform = client.fingerprint.platform; + const fieldConfig = getPlatformFieldConfig(platform); + + return ( + + {platform && ( + + + {t("platform")} + + +
+ {getPlatformIcon(platform)} + {formatPlatform(platform)} +
+
+
+ )} + + {client.fingerprint.osVersion && + fieldConfig.osVersion.show && ( + + + {t(fieldConfig.osVersion.labelKey)} + + + {client.fingerprint.osVersion} + + + )} + + {client.fingerprint.kernelVersion && + fieldConfig.kernelVersion.show && ( + + + {t("kernelVersion")} + + + {client.fingerprint.kernelVersion} + + + )} + + {client.fingerprint.arch && + fieldConfig.arch.show && ( + + + {t("architecture")} + + + {client.fingerprint.arch} + + + )} + + {client.fingerprint.deviceModel && + fieldConfig.deviceModel.show && ( + + + {t("deviceModel")} + + + {client.fingerprint.deviceModel} + + + )} + + {client.fingerprint.serialNumber && + fieldConfig.serialNumber.show && ( + + + {t("serialNumber")} + + + {client.fingerprint.serialNumber} + + + )} + + {client.fingerprint.username && + fieldConfig.username.show && ( + + + {t("username")} + + + {client.fingerprint.username} + + + )} + + {client.fingerprint.hostname && + fieldConfig.hostname.show && ( + + + {t("hostname")} + + + {client.fingerprint.hostname} + + + )} + + {client.fingerprint.firstSeen && ( + + + {t("firstSeen")} + + + {formatTimestamp( + client.fingerprint.firstSeen + )} + + + )} + + {client.fingerprint.lastSeen && ( + + + {t("lastSeen")} + + + {formatTimestamp( + client.fingerprint.lastSeen + )} + + + )} +
+ ); + })()} +
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx new file mode 100644 index 00000000..7d8059aa --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -0,0 +1,57 @@ +import ClientInfoCard from "@app/components/ClientInfoCard"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import ClientProvider from "@app/providers/ClientProvider"; +import { GetClientResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: number | string; orgId: string }>; +}; + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.niceId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + redirect(`/${params.orgId}/settings/clients/user`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("general"), + href: `/${params.orgId}/settings/clients/user/${params.niceId}/general` + } + ]; + + return ( + <> + + + +
+ + {children} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx new file mode 100644 index 00000000..9ad97186 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; niceId: number | string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/clients/user/${params.niceId}/general` + ); +} diff --git a/src/components/ActionBanner.tsx b/src/components/ActionBanner.tsx new file mode 100644 index 00000000..6e98b978 --- /dev/null +++ b/src/components/ActionBanner.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { type ReactNode } from "react"; +import { Card, CardContent } from "@app/components/ui/card"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { cva, type VariantProps } from "class-variance-authority"; + +const actionBannerVariants = cva( + "mb-6 relative overflow-hidden", + { + variants: { + variant: { + warning: "border-yellow-500/30 bg-gradient-to-br from-yellow-500/10 via-background to-background", + info: "border-blue-500/30 bg-gradient-to-br from-blue-500/10 via-background to-background", + success: "border-green-500/30 bg-gradient-to-br from-green-500/10 via-background to-background", + destructive: "border-red-500/30 bg-gradient-to-br from-red-500/10 via-background to-background", + default: "border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +const titleVariants = "text-lg font-semibold flex items-center gap-2"; + +const iconVariants = cva( + "w-5 h-5", + { + variants: { + variant: { + warning: "text-yellow-600 dark:text-yellow-500", + info: "text-blue-600 dark:text-blue-500", + success: "text-green-600 dark:text-green-500", + destructive: "text-red-600 dark:text-red-500", + default: "text-primary" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +type ActionBannerProps = { + title: string; + titleIcon?: ReactNode; + description: string; + actions?: ReactNode; + className?: string; +} & VariantProps; + +export function ActionBanner({ + title, + titleIcon, + description, + actions, + variant = "default", + className +}: ActionBannerProps) { + return ( + + +
+
+

+ {titleIcon && ( + + {titleIcon} + + )} + {title} +

+

+ {description} +

+
+ {actions && ( +
+ {actions} +
+ )} +
+
+
+ ); +} + +export default ActionBanner; diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index 8e7fa5e7..a50b6039 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -19,7 +19,11 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - + + + {t("name")} + {client.name} + {t("identifier")} {client.niceId} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index b1cc74a8..b00503c3 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -11,7 +11,7 @@ export function InfoSections({ }) { return (
( null ); @@ -152,8 +151,6 @@ export default function MachineClientsTable({ .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -421,8 +418,7 @@ export default function MachineClientsTable({ if (clientRow.blocked) { unblockClient(clientRow.id); } else { - setSelectedClient(clientRow); - setIsBlockModalOpen(true); + blockClient(clientRow.id); } }} > @@ -482,28 +478,6 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - {selectedClient && ( - { - setIsBlockModalOpen(val); - if (!val) { - setSelectedClient(null); - } - }} - dialog={ -
-

{t("blockClientQuestion")}

-

{t("blockClientMessage")}

-
- } - buttonText={t("blockClientConfirm")} - onConfirm={async () => blockClient(selectedClient!.id)} - string={selectedClient.name} - title={t("blockClient")} - /> - )} - ( null ); @@ -152,8 +151,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { .then(() => { startTransition(() => { router.refresh(); - setIsBlockModalOpen(false); - setSelectedClient(null); }); }); }; @@ -457,8 +454,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { if (clientRow.blocked) { unblockClient(clientRow.id); } else { - setSelectedClient(clientRow); - setIsBlockModalOpen(true); + blockClient(clientRow.id); } }} > @@ -484,7 +480,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)} - {client.fingerprint && (() => { - const platform = client.fingerprint.platform; - const fieldConfig = getPlatformFieldConfig(platform); - - return ( - - {platform && ( - - - {t("platform")} - - -
- {getPlatformIcon(platform)} - {formatPlatform(platform)} -
-
-
- )} + {client.fingerprint && + (() => { + const platform = client.fingerprint.platform; + const fieldConfig = + getPlatformFieldConfig(platform); - {client.fingerprint.osVersion && - fieldConfig.osVersion.show && ( + return ( + + {platform && ( - {t(fieldConfig.osVersion.labelKey)} + {t("platform")} - {client.fingerprint.osVersion} +
+ {getPlatformIcon( + platform + )} + + {formatPlatform( + platform + )} + +
)} - {client.fingerprint.kernelVersion && - fieldConfig.kernelVersion.show && ( + {client.fingerprint.osVersion && + fieldConfig.osVersion.show && ( + + + {t( + fieldConfig + .osVersion + .labelKey + )} + + + { + client.fingerprint + .osVersion + } + + + )} + + {client.fingerprint.kernelVersion && + fieldConfig.kernelVersion.show && ( + + + {t("kernelVersion")} + + + { + client.fingerprint + .kernelVersion + } + + + )} + + {client.fingerprint.arch && + fieldConfig.arch.show && ( + + + {t("architecture")} + + + { + client.fingerprint + .arch + } + + + )} + + {client.fingerprint.deviceModel && + fieldConfig.deviceModel.show && ( + + + {t("deviceModel")} + + + { + client.fingerprint + .deviceModel + } + + + )} + + {client.fingerprint.serialNumber && + fieldConfig.serialNumber.show && ( + + + {t("serialNumber")} + + + { + client.fingerprint + .serialNumber + } + + + )} + + {client.fingerprint.username && + fieldConfig.username.show && ( + + + {t("username")} + + + { + client.fingerprint + .username + } + + + )} + + {client.fingerprint.hostname && + fieldConfig.hostname.show && ( + + + {t("hostname")} + + + { + client.fingerprint + .hostname + } + + + )} + + {client.fingerprint.firstSeen && ( - {t("kernelVersion")} + {t("firstSeen")} - {client.fingerprint.kernelVersion} + {formatTimestamp( + client.fingerprint + .firstSeen + )} )} - {client.fingerprint.arch && - fieldConfig.arch.show && ( + {client.fingerprint.lastSeen && ( - {t("architecture")} + {t("lastSeen")} - {client.fingerprint.arch} + {formatTimestamp( + client.fingerprint + .lastSeen + )} )} - - {client.fingerprint.deviceModel && - fieldConfig.deviceModel.show && ( - - - {t("deviceModel")} - - - {client.fingerprint.deviceModel} - - - )} - - {client.fingerprint.serialNumber && - fieldConfig.serialNumber.show && ( - - - {t("serialNumber")} - - - {client.fingerprint.serialNumber} - - - )} - - {client.fingerprint.username && - fieldConfig.username.show && ( - - - {t("username")} - - - {client.fingerprint.username} - - - )} - - {client.fingerprint.hostname && - fieldConfig.hostname.show && ( - - - {t("hostname")} - - - {client.fingerprint.hostname} - - - )} - - {client.fingerprint.firstSeen && ( - - - {t("firstSeen")} - - - {formatTimestamp( - client.fingerprint.firstSeen - )} - - - )} - - {client.fingerprint.lastSeen && ( - - - {t("lastSeen")} - - - {formatTimestamp( - client.fingerprint.lastSeen - )} - - - )} -
- ); - })()} +
+ ); + })()}
)} diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index dee24532..35a2b2e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -41,6 +41,32 @@ export default async function ClientsPage(props: ClientsPageProps) { const mapClientToRow = ( client: ListClientsResponse["clients"][0] ): ClientRow => { + // Build fingerprint object if any fingerprint data exists + const hasFingerprintData = + (client as any).fingerprintPlatform || + (client as any).fingerprintOsVersion || + (client as any).fingerprintKernelVersion || + (client as any).fingerprintArch || + (client as any).fingerprintSerialNumber || + (client as any).fingerprintUsername || + (client as any).fingerprintHostname || + (client as any).deviceModel; + + const fingerprint = hasFingerprintData + ? { + platform: (client as any).fingerprintPlatform || null, + osVersion: (client as any).fingerprintOsVersion || null, + kernelVersion: + (client as any).fingerprintKernelVersion || null, + arch: (client as any).fingerprintArch || null, + deviceModel: (client as any).deviceModel || null, + serialNumber: + (client as any).fingerprintSerialNumber || null, + username: (client as any).fingerprintUsername || null, + hostname: (client as any).fingerprintHostname || null + } + : null; + return { name: client.name, id: client.clientId, @@ -58,7 +84,8 @@ export default async function ClientsPage(props: ClientsPageProps) { agent: client.agent, archived: client.archived || false, blocked: client.blocked || false, - approvalState: client.approvalState + approvalState: client.approvalState, + fingerprint }; }; diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index d85adae2..26d6fab1 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -27,7 +27,7 @@ import ClientDownloadBanner from "./ClientDownloadBanner"; import { Badge } from "./ui/badge"; import { build } from "@server/build"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type ClientRow = { id: number; @@ -48,6 +48,16 @@ export type ClientRow = { approvalState: "approved" | "pending" | "denied" | null; archived?: boolean; blocked?: boolean; + fingerprint?: { + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + username: string | null; + hostname: string | null; + } | null; }; type ClientTableProps = { @@ -55,10 +65,52 @@ type ClientTableProps = { orgId: string; }; +function formatPlatform(platform: string | null | undefined): string { + if (!platform) return "-"; + const platformMap: Record = { + macos: "macOS", + windows: "Windows", + linux: "Linux", + ios: "iOS", + android: "Android", + unknown: "Unknown" + }; + return platformMap[platform.toLowerCase()] || platform; +} + export default function UserDevicesTable({ userClients }: ClientTableProps) { const router = useRouter(); const t = useTranslations(); + const formatFingerprintInfo = (fingerprint: ClientRow["fingerprint"]): string => { + if (!fingerprint) return ""; + const parts: string[] = []; + + if (fingerprint.platform) { + parts.push(`${t("platform")}: ${formatPlatform(fingerprint.platform)}`); + } + if (fingerprint.deviceModel) { + parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`); + } + if (fingerprint.osVersion) { + parts.push(`${t("osVersion")}: ${fingerprint.osVersion}`); + } + if (fingerprint.arch) { + parts.push(`${t("architecture")}: ${fingerprint.arch}`); + } + if (fingerprint.hostname) { + parts.push(`${t("hostname")}: ${fingerprint.hostname}`); + } + if (fingerprint.username) { + parts.push(`${t("username")}: ${fingerprint.username}`); + } + if (fingerprint.serialNumber) { + parts.push(`${t("serialNumber")}: ${fingerprint.serialNumber}`); + } + + return parts.join("\n"); + }; + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null @@ -182,7 +234,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "name", enableHiding: false, - friendlyName: "Name", + friendlyName: t("name"), header: ({ column }) => { return ( ); }, cell: ({ row }) => { const r = row.original; + const fingerprintInfo = r.fingerprint + ? formatFingerprintInfo(r.fingerprint) + : null; return (
{r.name} + {fingerprintInfo && ( + +
+
+ {t("deviceInformation")} +
+
+ {fingerprintInfo} +
+
+
+ )} {r.archived && ( {t("archived")} @@ -250,7 +317,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "userEmail", - friendlyName: "User", + friendlyName: t("users"), header: ({ column }) => { return ( ); @@ -284,7 +351,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: "Connectivity", + friendlyName: t("online"), header: ({ column }) => { return ( ); @@ -306,14 +373,14 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return (
- Connected + {t("online")}
); } else { return (
- Disconnected + {t("offline")}
); } @@ -321,7 +388,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "mbIn", - friendlyName: "Data In", + friendlyName: t("dataIn"), header: ({ column }) => { return ( ); @@ -340,7 +407,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "mbOut", - friendlyName: "Data Out", + friendlyName: t("dataOut"), header: ({ column }) => { return ( ); @@ -399,7 +466,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "subnet", - friendlyName: "Address", + friendlyName: t("address"), header: ({ column }) => { return ( ); @@ -445,8 +512,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { > {clientRow.archived - ? "Unarchive" - : "Archive"} + ? t("actionUnarchiveClient") + : t("actionArchiveClient")} {clientRow.blocked - ? "Unblock" - : "Block"} + ? t("actionUnblockClient") + : t("actionBlockClient")} {!clientRow.userId && ( @@ -473,7 +540,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }} > - Delete + {t("delete")} )} @@ -483,7 +550,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { href={`/${clientRow.orgId}/settings/clients/user/${clientRow.niceId}`} > @@ -510,10 +577,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {

{t("clientMessageRemove")}

} - buttonText="Confirm Delete Client" + buttonText={t("actionDeleteClient")} onConfirm={async () => deleteClient(selectedClient!.id)} string={selectedClient.name} - title="Delete Client" + title={t("actionDeleteClient")} /> )} diff --git a/src/components/ui/info-popup.tsx b/src/components/ui/info-popup.tsx index cff1cce4..b7c0f55e 100644 --- a/src/components/ui/info-popup.tsx +++ b/src/components/ui/info-popup.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState, useRef, useEffect } from "react"; import { Info } from "lucide-react"; import { Popover, @@ -17,25 +17,61 @@ interface InfoPopupProps { } export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) { + const [open, setOpen] = useState(false); + const timeoutRef = useRef(null); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setOpen(true); + }; + + const handleMouseLeave = () => { + // Add a small delay to prevent flickering when moving between trigger and content + timeoutRef.current = setTimeout(() => { + setOpen(false); + }, 100); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + const defaultTrigger = ( ); + const triggerElement = trigger ?? defaultTrigger; + return (
{text && {text}} - - - {trigger ?? defaultTrigger} + + + {triggerElement} - + {children || (info && (

From d6965560979ca668d1b4d51613b5cc01d093d1d9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 18 Jan 2026 11:55:27 -0800 Subject: [PATCH 11/27] Handle disconnecting message when stoppng --- .../olm/handleOlmDisconnectingMessage.ts | 34 +++++++++++++++++++ server/routers/olm/index.ts | 1 + server/routers/ws/messageHandlers.ts | 4 ++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 server/routers/olm/handleOlmDisconnectingMessage.ts diff --git a/server/routers/olm/handleOlmDisconnectingMessage.ts b/server/routers/olm/handleOlmDisconnectingMessage.ts new file mode 100644 index 00000000..2ddd5e51 --- /dev/null +++ b/server/routers/olm/handleOlmDisconnectingMessage.ts @@ -0,0 +1,34 @@ +import { MessageHandler } from "@server/routers/ws"; +import { clients, db, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +/** + * Handles disconnecting messages from clients to show disconnected in the ui + */ +export const handleOlmDisconnecingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + online: false + }) + .where(eq(clients.clientId, olm.clientId)); + } catch (error) { + logger.error("Error handling disconnecting message", { error }); + } +}; diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index c9017911..f04ba0be 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -10,3 +10,4 @@ export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; export * from "./recoverOlmWithFingerprint"; +export * from "./handleOlmDisconnectingMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index bcf0b4dc..45c62e6c 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -14,7 +14,8 @@ import { handleOlmPingMessage, startOlmOfflineChecker, handleOlmServerPeerAddMessage, - handleOlmUnRelayMessage + handleOlmUnRelayMessage, + handleOlmDisconnecingMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { MessageHandler } from "./types"; @@ -25,6 +26,7 @@ export const messageHandlers: Record = { "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, "olm/ping": handleOlmPingMessage, + "olm/disconnecting": handleOlmDisconnecingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, "newt/wg/get-config": handleGetConfigMessage, From a3fa12f0e4933e6ba07f0d4bc5b36da1fc9017f6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 12:02:51 -0800 Subject: [PATCH 12/27] split org security settings to new tab --- src/app/[orgId]/settings/general/layout.tsx | 4 + src/app/[orgId]/settings/general/page.tsx | 712 +---------------- .../settings/general/security/page.tsx | 751 ++++++++++++++++++ 3 files changed, 757 insertions(+), 710 deletions(-) create mode 100644 src/app/[orgId]/settings/general/security/page.tsx diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 3ed9f0b2..53d03918 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -51,6 +51,10 @@ export default async function GeneralSettingsPage({ title: t("general"), href: `/{orgId}/settings/general`, exact: true + }, + { + title: t("security"), + href: `/{orgId}/settings/general/security` } ]; if (build !== "oss") { diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 3e78adc3..30285ff8 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,19 +1,12 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; import { useState, - useRef, useTransition, - useActionState, - type ComponentRef + useActionState } from "react"; import { Form, @@ -25,13 +18,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -55,79 +41,19 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import type { t } from "@faker-js/faker/dist/airline-DF6RqYmq"; import type { OrgContextType } from "@app/contexts/orgContext"; -// Session length options in hours -const SESSION_LENGTH_OPTIONS = [ - { value: null, labelKey: "unenforced" }, - { value: 1, labelKey: "1Hour" }, - { value: 3, labelKey: "3Hours" }, - { value: 6, labelKey: "6Hours" }, - { value: 12, labelKey: "12Hours" }, - { value: 24, labelKey: "1DaySession" }, - { value: 72, labelKey: "3Days" }, - { value: 168, labelKey: "7Days" }, - { value: 336, labelKey: "14Days" }, - { value: 720, labelKey: "30DaysSession" }, - { value: 2160, labelKey: "90DaysSession" }, - { value: 4320, labelKey: "180DaysSession" } -]; - -// Password expiry options in days - will be translated in component -const PASSWORD_EXPIRY_OPTIONS = [ - { value: null, labelKey: "neverExpire" }, - { value: 1, labelKey: "1Day" }, - { value: 30, labelKey: "30Days" }, - { value: 60, labelKey: "60Days" }, - { value: 90, labelKey: "90Days" }, - { value: 180, labelKey: "180Days" }, - { value: 365, labelKey: "1Year" } -]; - // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional(), - requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional(), - passwordExpiryDays: z.number().nullable().optional(), - settingsLogRetentionDaysRequest: z.number(), - settingsLogRetentionDaysAccess: z.number(), - settingsLogRetentionDaysAction: z.number() + subnet: z.string().optional() }); -type GeneralFormValues = z.infer; - -const LOG_RETENTION_OPTIONS = [ - { label: "logRetentionDisabled", value: 0 }, - { label: "logRetention3Days", value: 3 }, - { label: "logRetention7Days", value: 7 }, - { label: "logRetention14Days", value: 14 }, - { label: "logRetention30Days", value: 30 }, - { label: "logRetention90Days", value: 90 }, - ...(build != "saas" - ? [ - { label: "logRetentionForever", value: -1 }, - { label: "logRetentionEndOfFollowingYear", value: 9001 } - ] - : []) -]; - export default function GeneralPage() { const { org } = useOrgContext(); return ( - - - - {build !== "oss" && } {build !== "saas" && } ); @@ -340,637 +266,3 @@ function GeneralSectionForm({ org }: SectionFormProps) { ); } -function LogRetentionSectionForm({ org }: SectionFormProps) { - const form = useForm({ - resolver: zodResolver( - GeneralFormSchema.pick({ - settingsLogRetentionDaysRequest: true, - settingsLogRetentionDaysAccess: true, - settingsLogRetentionDaysAction: true - }) - ), - defaultValues: { - settingsLogRetentionDaysRequest: - org.settingsLogRetentionDaysRequest ?? 15, - settingsLogRetentionDaysAccess: - org.settingsLogRetentionDaysAccess ?? 15, - settingsLogRetentionDaysAction: - org.settingsLogRetentionDaysAction ?? 15 - }, - mode: "onChange" - }); - - const router = useRouter(); - const t = useTranslations(); - const { isPaidUser, hasSaasSubscription } = usePaidStatus(); - - const [, formAction, loadingSave] = useActionState(performSave, null); - const api = createApiClient(useEnvContext()); - - async function performSave() { - const isValid = await form.trigger(); - if (!isValid) return; - - const data = form.getValues(); - - try { - const reqData = { - settingsLogRetentionDaysRequest: - data.settingsLogRetentionDaysRequest, - settingsLogRetentionDaysAccess: - data.settingsLogRetentionDaysAccess, - settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction - } as any; - - // Update organization - await api.post(`/org/${org.orgId}`, reqData); - - toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) - }); - } - } - - return ( - - - {t("logRetention")} - - {t("logRetentionDescription")} - - - - -

- - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build !== "oss" && ( - <> - - - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - - - - -
- -
- - ); -} - -function SecuritySettingsSectionForm({ org }: SectionFormProps) { - const router = useRouter(); - const form = useForm({ - resolver: zodResolver( - GeneralFormSchema.pick({ - requireTwoFactor: true, - maxSessionLengthHours: true, - passwordExpiryDays: true - }) - ), - defaultValues: { - requireTwoFactor: org.requireTwoFactor || false, - maxSessionLengthHours: org.maxSessionLengthHours || null, - passwordExpiryDays: org.passwordExpiryDays || null - }, - mode: "onChange" - }); - const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); - - // Track initial security policy values - const initialSecurityValues = { - requireTwoFactor: org.requireTwoFactor || false, - maxSessionLengthHours: org.maxSessionLengthHours || null, - passwordExpiryDays: org.passwordExpiryDays || null - }; - - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = - useState(false); - - // Check if security policies have changed - const hasSecurityPolicyChanged = () => { - const currentValues = form.getValues(); - return ( - currentValues.requireTwoFactor !== - initialSecurityValues.requireTwoFactor || - currentValues.maxSessionLengthHours !== - initialSecurityValues.maxSessionLengthHours || - currentValues.passwordExpiryDays !== - initialSecurityValues.passwordExpiryDays - ); - }; - - const [, formAction, loadingSave] = useActionState(onSubmit, null); - const api = createApiClient(useEnvContext()); - - const formRef = useRef>(null); - - async function onSubmit() { - // Check if security policies have changed - if (hasSecurityPolicyChanged()) { - setIsSecurityPolicyConfirmOpen(true); - return; - } - - await performSave(); - } - - async function performSave() { - const isValid = await form.trigger(); - if (!isValid) return; - - const data = form.getValues(); - - try { - const reqData = { - requireTwoFactor: data.requireTwoFactor || false, - maxSessionLengthHours: data.maxSessionLengthHours, - passwordExpiryDays: data.passwordExpiryDays - } as any; - - // Update organization - await api.post(`/org/${org.orgId}`, reqData); - - toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) - }); - } - } - - return ( - <> - -

{t("securityPolicyChangeDescription")}

-
- } - buttonText={t("saveSettings")} - onConfirm={performSave} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - -
- - - { - const isDisabled = !isPaidUser; - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t("passwordExpiryDays")} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> - - -
-
- -
- -
-
- - ); -} diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx new file mode 100644 index 00000000..716e35d6 --- /dev/null +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -0,0 +1,751 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { + useState, + useRef, + useActionState, + type ComponentRef +} from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import type { OrgContextType } from "@app/contexts/orgContext"; + +// Session length options in hours +const SESSION_LENGTH_OPTIONS = [ + { value: null, labelKey: "unenforced" }, + { value: 1, labelKey: "1Hour" }, + { value: 3, labelKey: "3Hours" }, + { value: 6, labelKey: "6Hours" }, + { value: 12, labelKey: "12Hours" }, + { value: 24, labelKey: "1DaySession" }, + { value: 72, labelKey: "3Days" }, + { value: 168, labelKey: "7Days" }, + { value: 336, labelKey: "14Days" }, + { value: 720, labelKey: "30DaysSession" }, + { value: 2160, labelKey: "90DaysSession" }, + { value: 4320, labelKey: "180DaysSession" } +]; + +// Password expiry options in days - will be translated in component +const PASSWORD_EXPIRY_OPTIONS = [ + { value: null, labelKey: "neverExpire" }, + { value: 1, labelKey: "1Day" }, + { value: 30, labelKey: "30Days" }, + { value: 60, labelKey: "60Days" }, + { value: 90, labelKey: "90Days" }, + { value: 180, labelKey: "180Days" }, + { value: 365, labelKey: "1Year" } +]; + +// Schema for security organization settings +const SecurityFormSchema = z.object({ + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number() +}); + +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" + ? [ + { label: "logRetentionForever", value: -1 }, + { label: "logRetentionEndOfFollowingYear", value: 9001 } + ] + : []) +]; + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +export default function SecurityPage() { + const { org } = useOrgContext(); + return ( + + + {build !== "oss" && } + + ); +} + +function LogRetentionSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + settingsLogRetentionDaysRequest: true, + settingsLogRetentionDaysAccess: true, + settingsLogRetentionDaysAction: true + }) + ), + defaultValues: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.settingsLogRetentionDaysAction ?? 15 + }, + mode: "onChange" + }); + + const router = useRouter(); + const t = useTranslations(); + const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("logRetention")} + + {t("logRetentionDescription")} + + + + +
+ + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {build !== "oss" && ( + <> + + + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + +
+
+ +
+ +
+
+ ); +} + +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), + defaultValues: { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }, + mode: "onChange" + }); + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; + + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + + const [, formAction, loadingSave] = useActionState(onSubmit, null); + const api = createApiClient(useEnvContext()); + + const formRef = useRef>(null); + + async function onSubmit() { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(); + } + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + requireTwoFactor: data.requireTwoFactor || false, + maxSessionLengthHours: data.maxSessionLengthHours, + passwordExpiryDays: data.passwordExpiryDays + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + <> + +

{t("securityPolicyChangeDescription")}

+ + } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + { + const isDisabled = !isPaidUser; + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + +
+
+ +
+ +
+
+ + ); +} From 43c60bcdbc9fac7ff87b24509085a105a3f2d0bc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 12:08:29 -0800 Subject: [PATCH 13/27] spacing and phrase improvement --- messages/en-US.json | 2 +- src/components/CreateRoleForm.tsx | 2 +- src/components/Credenza.tsx | 2 +- src/components/EditRoleForm.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 7b553976..23078d05 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1581,7 +1581,7 @@ "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Time is in seconds", "requireDeviceApproval": "Require Device Approvals", - "requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources", + "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 8108461d..ba9863b5 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -161,7 +161,7 @@ export default function CreateRoleForm({ )} /> {build !== "oss" && ( -
+
{ return ( {build !== "oss" && ( -
+
Date: Sun, 18 Jan 2026 12:11:58 -0800 Subject: [PATCH 14/27] add olm container install commands --- .../settings/clients/machine/create/page.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/machine/create/page.tsx b/src/app/[orgId]/settings/clients/machine/create/page.tsx index 5fb12b46..d52e5364 100644 --- a/src/app/[orgId]/settings/clients/machine/create/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/create/page.tsx @@ -73,9 +73,10 @@ type CommandItem = string | { title: string; command: string }; type Commands = { unix: Record; windows: Record; + docker: Record; }; -const platforms = ["unix", "windows"] as const; +const platforms = ["unix", "docker", "windows"] as const; type Platform = (typeof platforms)[number]; @@ -156,6 +157,27 @@ export default function Page() { command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` } ] + }, + docker: { + "Docker Compose": [ + `services: + olm: + image: fosrl/olm + container_name: olm + restart: unless-stopped + network_mode: host + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - PANGOLIN_ENDPOINT=${endpoint} + - OLM_ID=${id} + - OLM_SECRET=${secret}` + ], + "Docker Run": [ + `docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] } }; setCommands(commands); @@ -167,6 +189,8 @@ export default function Page() { return ["All"]; case "windows": return ["x64"]; + case "docker": + return ["Docker Compose", "Docker Run"]; default: return ["x64"]; } From 89928c753c3423e9f1b9f56869fa5ac8a85696f6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 12:19:07 -0800 Subject: [PATCH 15/27] add server info endpoint --- server/routers/external.ts | 3 ++ server/routers/serverInfo/getServerInfo.ts | 60 ++++++++++++++++++++++ server/routers/serverInfo/index.ts | 1 + 3 files changed, 64 insertions(+) create mode 100644 server/routers/serverInfo/getServerInfo.ts create mode 100644 server/routers/serverInfo/index.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 3ea60983..2287ee26 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -18,6 +18,7 @@ import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; import * as newt from "./newt"; import * as olm from "./olm"; +import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -712,6 +713,8 @@ authenticated.get( authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); +authenticated.get(`/server-info`, serverInfo.getServerInfo); + authenticated.post( `/supporter-key/validate`, supporterKey.validateSupporterKey diff --git a/server/routers/serverInfo/getServerInfo.ts b/server/routers/serverInfo/getServerInfo.ts new file mode 100644 index 00000000..fa71cedc --- /dev/null +++ b/server/routers/serverInfo/getServerInfo.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import config from "@server/lib/config"; +import { build } from "@server/build"; +import { APP_VERSION } from "@server/lib/consts"; +import license from "#dynamic/license/license"; + +export type GetServerInfoResponse = { + version: string; + supporterStatusValid: boolean; + build: "oss" | "enterprise" | "saas"; + enterpriseLicenseValid: boolean; + enterpriseLicenseType: string | null; +}; + +export async function getServerInfo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const supporterData = config.getSupporterData(); + const supporterStatusValid = supporterData?.valid || false; + + let enterpriseLicenseValid = false; + let enterpriseLicenseType: string | null = null; + + if (build === "enterprise") { + try { + const licenseStatus = await license.check(); + enterpriseLicenseValid = licenseStatus.isLicenseValid; + enterpriseLicenseType = licenseStatus.tier || null; + } catch (error) { + logger.warn("Failed to check enterprise license status:", error); + } + } + + return sendResponse(res, { + data: { + version: APP_VERSION, + supporterStatusValid, + build, + enterpriseLicenseValid, + enterpriseLicenseType + }, + success: true, + error: false, + message: "Server info retrieved", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/serverInfo/index.ts b/server/routers/serverInfo/index.ts new file mode 100644 index 00000000..1bbfbdba --- /dev/null +++ b/server/routers/serverInfo/index.ts @@ -0,0 +1 @@ +export * from "./getServerInfo"; From c03a61f6137525f2e6b87b1509f2b9c48aa46fdc Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 18 Jan 2026 14:59:49 -0800 Subject: [PATCH 16/27] Delete each of the site resources and rebuild --- server/routers/site/deleteSite.ts | 47 ++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 55423c01..94d9d920 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -11,6 +11,7 @@ import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -63,23 +64,37 @@ export async function deleteSite( let deletedNewtId: string | null = null; await db.transaction(async (trx) => { - if (site.pubKey) { - if (site.type == "wireguard") { + if (site.type == "wireguard") { + if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, siteId)) - .returning(); - if (deletedNewt) { - deletedNewtId = deletedNewt.newtId; + } + } else if (site.type == "newt") { + // delete all of the site resources on this site + const siteResourcesOnSite = trx + .delete(siteResources) + .where(eq(siteResources.siteId, siteId)) + .returning(); - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where(eq(newtSessions.newtId, deletedNewt.newtId)); - } + // loop through them + for (const removedSiteResource of await siteResourcesOnSite) { + await rebuildClientAssociationsFromSiteResource( + removedSiteResource, + trx + ); + } + + // get the newt on the site by querying the newt table for siteId + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, siteId)) + .returning(); + if (deletedNewt) { + deletedNewtId = deletedNewt.newtId; + + // delete all of the sessions for the newt + await trx + .delete(newtSessions) + .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } From 8ae327e8f5ab32182a8c36ce6552f29903dfbb9a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 21:24:08 -0800 Subject: [PATCH 17/27] fix org policy check --- server/routers/olm/handleOlmRegisterMessage.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 19c240ad..c3616407 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -125,10 +125,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } - if ( - policyCheck?.policies?.passwordAge && - !policyCheck.policies.passwordAge.compliant - ) { + if (!policyCheck.policies?.passwordAge?.compliant === false) { logger.warn( `Olm user ${olm.userId} has non-compliant password age for org ${orgId}` ); @@ -138,8 +135,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); return; } else if ( - policyCheck?.policies?.maxSessionLength && - !policyCheck.policies.maxSessionLength.compliant + !policyCheck.policies?.maxSessionLength?.compliant === false ) { logger.warn( `Olm user ${olm.userId} has non-compliant session length for org ${orgId}` @@ -149,10 +145,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { olm.olmId ); return; - } else if ( - policyCheck?.policies && - !policyCheck.policies.requiredTwoFactor - ) { + } else if (policyCheck.policies?.requiredTwoFactor === false) { logger.warn( `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` ); From 6ec8d143facc75121b7a420b68d9870b6fdc9e45 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 21:47:00 -0800 Subject: [PATCH 18/27] hide pending approval filter in oss --- src/components/UserDevicesTable.tsx | 83 ++++++++++++++++++----------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 26d6fab1..102014e3 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -82,12 +82,16 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const router = useRouter(); const t = useTranslations(); - const formatFingerprintInfo = (fingerprint: ClientRow["fingerprint"]): string => { + const formatFingerprintInfo = ( + fingerprint: ClientRow["fingerprint"] + ): string => { if (!fingerprint) return ""; const parts: string[] = []; if (fingerprint.platform) { - parts.push(`${t("platform")}: ${formatPlatform(fingerprint.platform)}`); + parts.push( + `${t("platform")}: ${formatPlatform(fingerprint.platform)}` + ); } if (fingerprint.deviceModel) { parts.push(`${t("deviceModel")}: ${fingerprint.deviceModel}`); @@ -562,6 +566,49 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return baseColumns; }, [hasRowsWithoutUserId, t]); + const statusFilterOptions = useMemo(() => { + const allOptions = [ + { + id: "active", + label: t("active"), + value: "active" + }, + { + id: "pending", + label: t("pendingApproval"), + value: "pending" + }, + { + id: "denied", + label: t("deniedApproval"), + value: "denied" + }, + { + id: "archived", + label: t("archived"), + value: "archived" + }, + { + id: "blocked", + label: t("blocked"), + value: "blocked" + } + ]; + + if (build === "oss") { + return allOptions.filter((option) => option.value !== "pending"); + } + + return allOptions; + }, [t]); + + const statusFilterDefaultValues = useMemo(() => { + if (build === "oss") { + return ["active"]; + } + return ["active", "pending"]; + }, []); + return ( <> {selectedClient && !selectedClient.userId && ( @@ -604,33 +651,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { label: t("status") || "Status", multiSelect: true, displayMode: "calculated", - options: [ - { - id: "active", - label: t("active"), - value: "active" - }, - { - id: "pending", - label: t("pendingApproval"), - value: "pending" - }, - { - id: "denied", - label: t("deniedApproval"), - value: "denied" - }, - { - id: "archived", - label: t("archived"), - value: "archived" - }, - { - id: "blocked", - label: t("blocked"), - value: "blocked" - } - ], + options: statusFilterOptions, filterFn: ( row: ClientRow, selectedValues: (string | number | boolean)[] @@ -639,7 +660,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const rowArchived = row.archived; const rowBlocked = row.blocked; const approvalState = row.approvalState; - const isActive = !rowArchived && !rowBlocked; + const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied"; if (selectedValues.includes("active") && isActive) return true; @@ -665,7 +686,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return true; return false; }, - defaultValues: ["active", "pending"] // Default to showing active clients + defaultValues: statusFilterDefaultValues } ]} /> From 355265cd1ec41648022bc641449a9d9e08190dc7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 21:49:15 -0800 Subject: [PATCH 19/27] show paid user alert on approvals --- src/app/[orgId]/settings/(private)/access/approvals/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx index 0fef0d78..de62c189 100644 --- a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx +++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx @@ -1,4 +1,5 @@ import { ApprovalFeed } from "@app/components/ApprovalFeed"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -42,6 +43,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { title={t("accessApprovalsManage")} description={t("accessApprovalsDescription")} /> + + +
From acca1b6a91d1b93e143137f87884a9a30291916e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 18 Jan 2026 22:10:34 -0800 Subject: [PATCH 20/27] improve red alert colors --- src/components/ui/alert.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 6ec5f0b7..dce25949 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -11,7 +11,7 @@ const alertVariants = cva( default: "bg-card border text-foreground", neutral: "bg-card bg-muted border text-foreground", destructive: - "border-destructive/50 border bg-destructive/8 text-destructive dark:border-destructive/50 [&>svg]:text-destructive", + "border-destructive/50 border bg-destructive/8 dark:text-red-200 text-red-900 dark:border-destructive/50 [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", info: "border-blue-500/50 border bg-blue-500/10 text-blue-800 dark:text-blue-400 dark:border-blue-400 [&>svg]:text-blue-500", From 14a4b1b4b4e752f5d620242752b1c9e251708264 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 19 Jan 2026 11:39:58 -0800 Subject: [PATCH 21/27] add clear license key command to pangctl --- cli/commands/clearLicenseKeys.ts | 36 ++++++++++++++++++++++++++++++++ cli/index.ts | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 cli/commands/clearLicenseKeys.ts diff --git a/cli/commands/clearLicenseKeys.ts b/cli/commands/clearLicenseKeys.ts new file mode 100644 index 00000000..704641d3 --- /dev/null +++ b/cli/commands/clearLicenseKeys.ts @@ -0,0 +1,36 @@ +import { CommandModule } from "yargs"; +import { db, licenseKey } from "@server/db"; +import { eq } from "drizzle-orm"; + +type ClearLicenseKeysArgs = { }; + +export const clearLicenseKeys: CommandModule< + {}, + ClearLicenseKeysArgs +> = { + command: "clear-license-keys", + describe: + "Clear all license keys from the database", + // no args + builder: (yargs) => { + return yargs; + }, + handler: async (argv: {}) => { + try { + + console.log(`Clearing all license keys from the database`); + + // Delete all license keys + const deletedCount = await db + .delete(licenseKey) + .where(eq(licenseKey.licenseKeyId, licenseKey.licenseKeyId)) .returning();; // delete all + + console.log(`Deleted ${deletedCount.length} license key(s) from the database`); + + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index f44f41ba..328520aa 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -6,6 +6,7 @@ import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; import { clearExitNodes } from "./commands/clearExitNodes"; import { rotateServerSecret } from "./commands/rotateServerSecret"; +import { clearLicenseKeys } from "./commands/clearLicenseKeys"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -13,5 +14,6 @@ yargs(hideBin(process.argv)) .command(resetUserSecurityKeys) .command(clearExitNodes) .command(rotateServerSecret) + .command(clearLicenseKeys) .demandCommand() .help().argv; From fd6a3e5a179083aa57bf1cb74f4bd235dce3c28e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 19 Jan 2026 11:47:14 -0800 Subject: [PATCH 22/27] fix default logo size --- src/app/auth/login/device/success/page.tsx | 6 +++--- src/components/DashboardLoginForm.tsx | 4 ++-- src/components/DeviceLoginForm.tsx | 4 ++-- src/components/LoginCardHeader.tsx | 4 ++-- src/components/OrgSelectionForm.tsx | 4 ++-- src/components/SignupForm.tsx | 4 ++-- src/components/private/IdpLoginButtons.tsx | 1 + 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx index 6ee49587..dab60935 100644 --- a/src/app/auth/login/device/success/page.tsx +++ b/src/app/auth/login/device/success/page.tsx @@ -18,8 +18,8 @@ export default function DeviceAuthSuccessPage() { ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; useEffect(() => { // Detect if we're on iOS or Android @@ -82,4 +82,4 @@ export default function DeviceAuthSuccessPage() {

); -} \ No newline at end of file +} diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index f57eb8b1..8a4c611e 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -57,8 +57,8 @@ export default function DashboardLoginForm({ ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; return ( diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 914b43b0..cadeb230 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -195,8 +195,8 @@ export default function DeviceLoginForm({ ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; function onCancel() { setMetadata(null); diff --git a/src/components/LoginCardHeader.tsx b/src/components/LoginCardHeader.tsx index 5087b869..3386ee5d 100644 --- a/src/components/LoginCardHeader.tsx +++ b/src/components/LoginCardHeader.tsx @@ -17,8 +17,8 @@ export default function LoginCardHeader({ subtitle }: LoginCardHeaderProps) { ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; return ( diff --git a/src/components/OrgSelectionForm.tsx b/src/components/OrgSelectionForm.tsx index c625008e..ab58f083 100644 --- a/src/components/OrgSelectionForm.tsx +++ b/src/components/OrgSelectionForm.tsx @@ -42,8 +42,8 @@ export function OrgSelectionForm() { ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; const handleSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx index f20856b4..9cb93757 100644 --- a/src/components/SignupForm.tsx +++ b/src/components/SignupForm.tsx @@ -201,8 +201,8 @@ export default function SignupForm({ ? env.branding.logo?.authPage?.width || 175 : 175; const logoHeight = isUnlocked() - ? env.branding.logo?.authPage?.height || 58 - : 58; + ? env.branding.logo?.authPage?.height || 44 + : 44; const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp); const orgBannerHref = redirect diff --git a/src/components/private/IdpLoginButtons.tsx b/src/components/private/IdpLoginButtons.tsx index 7649bfde..50d84981 100644 --- a/src/components/private/IdpLoginButtons.tsx +++ b/src/components/private/IdpLoginButtons.tsx @@ -133,6 +133,7 @@ export default function IdpLoginButtons({ loginWithIdp(idp.idpId); }} disabled={loading} + loading={loading} > {effectiveType === "google" && ( Date: Mon, 19 Jan 2026 11:50:56 -0800 Subject: [PATCH 23/27] fix only show advanced toggle on newt sites --- .../[orgId]/settings/sites/create/page.tsx | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index bf3239d0..515c2c13 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -728,26 +728,28 @@ WantedBy=default.target` )} /> -
- -
+ {form.watch("method") === "newt" && ( +
+ +
+ )} {form.watch("method") === "newt" && showAdvancedSettings && ( Date: Mon, 19 Jan 2026 14:59:00 -0800 Subject: [PATCH 24/27] Move up figerprint so it happens before block --- .../routers/olm/handleOlmRegisterMessage.ts | 131 +++++++++--------- 1 file changed, 65 insertions(+), 66 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index c3616407..4e3d29f6 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -45,6 +45,71 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + if (fingerprint) { + const [existingFingerprint] = await db + .select() + .from(fingerprints) + .where(eq(fingerprints.olmId, olm.olmId)) + .limit(1); + + if (!existingFingerprint) { + await db.insert(fingerprints).values({ + olmId: olm.olmId, + firstSeen: now, + lastSeen: now, + + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint + }); + } else { + await db + .update(fingerprints) + .set({ + lastSeen: now, + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint + }) + .where(eq(fingerprints.olmId, olm.olmId)); + } + } + + if (postures) { + await db.insert(clientPostureSnapshots).values({ + clientId: olm.clientId, + + biometricsEnabled: postures?.biometricsEnabled, + diskEncrypted: postures?.diskEncrypted, + firewallEnabled: postures?.firewallEnabled, + autoUpdatesEnabled: postures?.autoUpdatesEnabled, + tpmAvailable: postures?.tpmAvailable, + + windowsDefenderEnabled: postures?.windowsDefenderEnabled, + + macosSipEnabled: postures?.macosSipEnabled, + macosGatekeeperEnabled: postures?.macosGatekeeperEnabled, + macosFirewallStealthMode: postures?.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures?.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures?.linuxSELinuxEnabled, + + collectedAt: now + }); + } + const [client] = await db .select() .from(clients) @@ -243,72 +308,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { relay ); - if (fingerprint) { - const [existingFingerprint] = await db - .select() - .from(fingerprints) - .where(eq(fingerprints.olmId, olm.olmId)) - .limit(1); - - if (!existingFingerprint) { - await db.insert(fingerprints).values({ - olmId: olm.olmId, - firstSeen: now, - lastSeen: now, - - username: fingerprint.username, - hostname: fingerprint.hostname, - platform: fingerprint.platform, - osVersion: fingerprint.osVersion, - kernelVersion: fingerprint.kernelVersion, - arch: fingerprint.arch, - deviceModel: fingerprint.deviceModel, - serialNumber: fingerprint.serialNumber, - platformFingerprint: fingerprint.platformFingerprint - }); - } else { - await db - .update(fingerprints) - .set({ - lastSeen: now, - - username: fingerprint.username, - hostname: fingerprint.hostname, - platform: fingerprint.platform, - osVersion: fingerprint.osVersion, - kernelVersion: fingerprint.kernelVersion, - arch: fingerprint.arch, - deviceModel: fingerprint.deviceModel, - serialNumber: fingerprint.serialNumber, - platformFingerprint: fingerprint.platformFingerprint - }) - .where(eq(fingerprints.olmId, olm.olmId)); - } - } - - if (postures && olm.clientId) { - await db.insert(clientPostureSnapshots).values({ - clientId: olm.clientId, - - biometricsEnabled: postures?.biometricsEnabled, - diskEncrypted: postures?.diskEncrypted, - firewallEnabled: postures?.firewallEnabled, - autoUpdatesEnabled: postures?.autoUpdatesEnabled, - tpmAvailable: postures?.tpmAvailable, - - windowsDefenderEnabled: postures?.windowsDefenderEnabled, - - macosSipEnabled: postures?.macosSipEnabled, - macosGatekeeperEnabled: postures?.macosGatekeeperEnabled, - macosFirewallStealthMode: postures?.macosFirewallStealthMode, - - linuxAppArmorEnabled: postures?.linuxAppArmorEnabled, - linuxSELinuxEnabled: postures?.linuxSELinuxEnabled, - - collectedAt: now - }); - } - // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES // if (siteConfigurations.length === 0) { // logger.warn("No valid site configurations found"); From 6765d5ad26d6fe75652f719daddd360cc6968a5a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 19 Jan 2026 16:30:34 -0800 Subject: [PATCH 25/27] Reorder setting the olm agent and version --- .../routers/olm/handleOlmRegisterMessage.ts | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 4e3d29f6..f21705dd 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -69,21 +69,36 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { platformFingerprint: fingerprint.platformFingerprint }); } else { - await db - .update(fingerprints) - .set({ - lastSeen: now, - username: fingerprint.username, - hostname: fingerprint.hostname, - platform: fingerprint.platform, - osVersion: fingerprint.osVersion, - kernelVersion: fingerprint.kernelVersion, - arch: fingerprint.arch, - deviceModel: fingerprint.deviceModel, - serialNumber: fingerprint.serialNumber, - platformFingerprint: fingerprint.platformFingerprint - }) - .where(eq(fingerprints.olmId, olm.olmId)); + const hasChanges = + existingFingerprint.username !== fingerprint.username || + existingFingerprint.hostname !== fingerprint.hostname || + existingFingerprint.platform !== fingerprint.platform || + existingFingerprint.osVersion !== fingerprint.osVersion || + existingFingerprint.kernelVersion !== + fingerprint.kernelVersion || + existingFingerprint.arch !== fingerprint.arch || + existingFingerprint.deviceModel !== fingerprint.deviceModel || + existingFingerprint.serialNumber !== fingerprint.serialNumber || + existingFingerprint.platformFingerprint !== + fingerprint.platformFingerprint; + + if (hasChanges) { + await db + .update(fingerprints) + .set({ + lastSeen: now, + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint + }) + .where(eq(fingerprints.olmId, olm.olmId)); + } } } @@ -110,6 +125,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { }); } + if ( + (olmVersion && olm.version !== olmVersion) || + (olmAgent && olm.agent !== olmAgent) || + olm.archived + ) { + await db + .update(olms) + .set({ + version: olmVersion, + agent: olmAgent, + archived: false + }) + .where(eq(olms.olmId, olm.olmId)); + } + const [client] = await db .select() .from(clients) @@ -237,21 +267,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } - if ( - (olmVersion && olm.version !== olmVersion) || - (olmAgent && olm.agent !== olmAgent) || - olm.archived - ) { - await db - .update(olms) - .set({ - version: olmVersion, - agent: olmAgent, - archived: false - }) - .where(eq(olms.olmId, olm.olmId)); - } - if (client.pubKey !== publicKey || client.archived) { logger.info( "Public key mismatch. Updating public key and clearing session info..." From 7ae6b2df05324e02b1208b289a3ad22dd6e203ca Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 19 Jan 2026 16:45:15 -0800 Subject: [PATCH 26/27] Fix email parsing validation error? --- server/routers/user/createOrgUser.ts | 9 ++------- src/app/[orgId]/settings/access/users/create/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index e1902477..b9a1abc9 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -23,15 +23,10 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ email: z + .string() .email() .toLowerCase() - .optional() - .refine((data) => { - if (data) { - return z.email().safeParse(data).success; - } - return true; - }), + .optional(), username: z.string().nonempty().toLowerCase(), name: z.string().optional(), type: z.enum(["internal", "oidc"]).optional(), diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 3199d817..0e55ffeb 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -361,7 +361,7 @@ export default function Page() { const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, @@ -403,7 +403,7 @@ export default function Page() { const res = await api .put(`/org/${orgId}/user`, { username: values.username, - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, From 1a36cd0317170c35078005ffbab8d54b929d1aa0 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 19 Jan 2026 17:57:55 -0800 Subject: [PATCH 27/27] Fix linting errors --- server/private/routers/ws/ws.ts | 2 +- server/routers/olm/handleOlmPingMessage.ts | 2 +- server/routers/olm/sync.ts | 2 +- server/setup/scriptsSqlite/1.13.0.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index e1fe3f54..342dba58 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -493,7 +493,7 @@ const sendToClientLocal = async ( } // Handle config version - let configVersion = await getClientConfigVersion(clientId); + const configVersion = await getClientConfigVersion(clientId); // Add config version to message const messageWithVersion = { diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 1fcd85b5..f0999f4f 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -178,7 +178,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { logger.debug(`handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})`); if (configVersion == null || configVersion === undefined) { - logger.debug(`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`) + logger.debug(`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`); } if (message.configVersion != null && configVersion != null && configVersion != message.configVersion) { diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts index 6147919c..d4ecd22c 100644 --- a/server/routers/olm/sync.ts +++ b/server/routers/olm/sync.ts @@ -66,7 +66,7 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) { }); } - logger.debug("sendOlmSyncMessage: sending sync message") + logger.debug("sendOlmSyncMessage: sending sync message"); await sendToClient(olm.olmId, { type: "olm/sync", diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index df8d7344..c4b49495 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -305,7 +305,7 @@ export default async function migration() { const subnets = site.remoteSubnets.split(","); for (const subnet of subnets) { // Generate a unique niceId for each new site resource - let niceId = generateName(); + const niceId = generateName(); insertCidrResource.run( site.siteId, subnet.trim(),